diff --git a/crossview-go-server/api/controllers/kubernetes/kubernetes_controller.go b/crossview-go-server/api/controllers/kubernetes/kubernetes_controller.go
index 5a679b29..2fb20e68 100644
--- a/crossview-go-server/api/controllers/kubernetes/kubernetes_controller.go
+++ b/crossview-go-server/api/controllers/kubernetes/kubernetes_controller.go
@@ -5,21 +5,37 @@ import (
"net/http"
"strconv"
"strings"
+ "time"
"crossview-go-server/lib"
+ "crossview-go-server/models"
"crossview-go-server/services"
"github.com/gin-gonic/gin"
)
type KubernetesController struct {
- logger lib.Logger
- kubernetesService services.KubernetesServiceInterface
+ logger lib.Logger
+ kubernetesService services.KubernetesServiceInterface
+ resourceEventRepo *models.ResourceEventRepository
+ resourceMetricRepo *models.ResourceMetricRepository
}
-func NewKubernetesController(logger lib.Logger, kubernetesService services.KubernetesServiceInterface) KubernetesController {
+func NewKubernetesController(
+ logger lib.Logger,
+ kubernetesService services.KubernetesServiceInterface,
+ database lib.Database,
+) KubernetesController {
+ var eventRepo *models.ResourceEventRepository
+ var metricRepo *models.ResourceMetricRepository
+ if database.DB != nil {
+ eventRepo = models.NewResourceEventRepository(database.DB)
+ metricRepo = models.NewResourceMetricRepository(database.DB)
+ }
return KubernetesController{
- logger: logger,
- kubernetesService: kubernetesService,
+ logger: logger,
+ kubernetesService: kubernetesService,
+ resourceEventRepo: eventRepo,
+ resourceMetricRepo: metricRepo,
}
}
@@ -218,6 +234,182 @@ func (c *KubernetesController) GetManagedResources(ctx *gin.Context) {
ctx.JSON(http.StatusOK, result)
}
+func (c *KubernetesController) GetResourceTree(ctx *gin.Context) {
+ apiVersion := ctx.Query("apiVersion")
+ kind := ctx.Query("kind")
+ name := ctx.Query("name")
+ namespace := ctx.Query("namespace")
+ contextName := ctx.Query("context")
+
+ if apiVersion == "" || kind == "" || name == "" {
+ ctx.JSON(http.StatusBadRequest, gin.H{"error": "apiVersion, kind, and name parameters are required"})
+ return
+ }
+
+ if namespace == "undefined" || namespace == "null" {
+ namespace = ""
+ }
+
+ maxDepth := 5
+ if depthStr := ctx.Query("depth"); depthStr != "" {
+ if d, err := strconv.Atoi(depthStr); err == nil && d > 0 {
+ maxDepth = d
+ }
+ }
+
+ tree, err := c.kubernetesService.GetResourceTree(apiVersion, kind, name, namespace, contextName, maxDepth)
+ if err != nil {
+ c.logger.Errorf("Failed to get resource tree: %s", err.Error())
+ ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+ return
+ }
+
+ ctx.JSON(http.StatusOK, tree)
+}
+
+func (c *KubernetesController) GetResourceDrift(ctx *gin.Context) {
+ apiVersion := ctx.Query("apiVersion")
+ kind := ctx.Query("kind")
+ name := ctx.Query("name")
+ namespace := ctx.Query("namespace")
+ contextName := ctx.Query("context")
+
+ if apiVersion == "" || kind == "" || name == "" {
+ ctx.JSON(http.StatusBadRequest, gin.H{"error": "apiVersion, kind, and name parameters are required"})
+ return
+ }
+
+ if namespace == "undefined" || namespace == "null" {
+ namespace = ""
+ }
+
+ drift, err := c.kubernetesService.GetResourceDrift(apiVersion, kind, name, namespace, contextName)
+ if err != nil {
+ c.logger.Errorf("Failed to get resource drift: %s", err.Error())
+ ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+ return
+ }
+
+ ctx.JSON(http.StatusOK, drift)
+}
+
+func (c *KubernetesController) GetResourceHistory(ctx *gin.Context) {
+ if c.resourceEventRepo == nil {
+ ctx.JSON(http.StatusNotImplemented, gin.H{"error": "History tracking requires database to be enabled"})
+ return
+ }
+
+ apiVersion := ctx.Query("apiVersion")
+ kind := ctx.Query("kind")
+ name := ctx.Query("name")
+ namespace := ctx.Query("namespace")
+ contextName := ctx.Query("context")
+
+ if apiVersion == "" || kind == "" || name == "" {
+ ctx.JSON(http.StatusBadRequest, gin.H{"error": "apiVersion, kind, and name parameters are required"})
+ return
+ }
+
+ if namespace == "undefined" || namespace == "null" {
+ namespace = ""
+ }
+
+ limit := 50
+ if limitStr := ctx.Query("limit"); limitStr != "" {
+ if l, err := strconv.Atoi(limitStr); err == nil && l > 0 {
+ limit = l
+ }
+ }
+
+ events, err := c.resourceEventRepo.FindByResource(apiVersion, kind, name, namespace, contextName, limit)
+ if err != nil {
+ c.logger.Errorf("Failed to get resource history: %s", err.Error())
+ ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+ return
+ }
+
+ ctx.JSON(http.StatusOK, gin.H{"events": events})
+}
+
+func (c *KubernetesController) GetRecentHistory(ctx *gin.Context) {
+ if c.resourceEventRepo == nil {
+ ctx.JSON(http.StatusNotImplemented, gin.H{"error": "History tracking requires database to be enabled"})
+ return
+ }
+
+ contextName := ctx.Query("context")
+ limit := 20
+ if limitStr := ctx.Query("limit"); limitStr != "" {
+ if l, err := strconv.Atoi(limitStr); err == nil && l > 0 {
+ limit = l
+ }
+ }
+
+ events, err := c.resourceEventRepo.FindRecent(contextName, limit)
+ if err != nil {
+ c.logger.Errorf("Failed to get recent history: %s", err.Error())
+ ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+ return
+ }
+
+ ctx.JSON(http.StatusOK, gin.H{"events": events})
+}
+
+func (c *KubernetesController) GetMetricsSummary(ctx *gin.Context) {
+ if c.resourceMetricRepo == nil {
+ ctx.JSON(http.StatusNotImplemented, gin.H{"error": "Metrics requires database to be enabled"})
+ return
+ }
+
+ contextName := ctx.Query("context")
+ if contextName == "" {
+ contextName = c.kubernetesService.GetCurrentContext()
+ }
+
+ metric, err := c.resourceMetricRepo.FindLatest(contextName)
+ if err != nil {
+ ctx.JSON(http.StatusOK, gin.H{"message": "No metrics collected yet"})
+ return
+ }
+
+ ctx.JSON(http.StatusOK, metric)
+}
+
+func (c *KubernetesController) GetHealthTrend(ctx *gin.Context) {
+ if c.resourceMetricRepo == nil {
+ ctx.JSON(http.StatusNotImplemented, gin.H{"error": "Metrics requires database to be enabled"})
+ return
+ }
+
+ contextName := ctx.Query("context")
+ if contextName == "" {
+ contextName = c.kubernetesService.GetCurrentContext()
+ }
+
+ to := time.Now()
+ from := to.Add(-24 * time.Hour)
+
+ if fromStr := ctx.Query("from"); fromStr != "" {
+ if t, err := time.Parse(time.RFC3339, fromStr); err == nil {
+ from = t
+ }
+ }
+ if toStr := ctx.Query("to"); toStr != "" {
+ if t, err := time.Parse(time.RFC3339, toStr); err == nil {
+ to = t
+ }
+ }
+
+ metrics, err := c.resourceMetricRepo.FindByTimeRange(contextName, from, to, 500)
+ if err != nil {
+ c.logger.Errorf("Failed to get health trend: %s", err.Error())
+ ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+ return
+ }
+
+ ctx.JSON(http.StatusOK, gin.H{"metrics": metrics})
+}
+
func (c *KubernetesController) AddKubeConfig(ctx *gin.Context) {
var request struct {
KubeConfig string `json:"kubeConfig"`
diff --git a/crossview-go-server/api/controllers/kubernetes/kubernetes_controller_test.go b/crossview-go-server/api/controllers/kubernetes/kubernetes_controller_test.go
index 3014600d..175e3bbc 100644
--- a/crossview-go-server/api/controllers/kubernetes/kubernetes_controller_test.go
+++ b/crossview-go-server/api/controllers/kubernetes/kubernetes_controller_test.go
@@ -1,6 +1,7 @@
package kubernetes
import (
+ "crossview-go-server/lib"
"encoding/json"
"fmt"
"net/http"
@@ -13,7 +14,7 @@ func TestKubernetesController_GetStatus(t *testing.T) {
logger := setupTestLogger()
mockService := setupMockKubernetesService()
- controller := NewKubernetesController(logger, mockService)
+ controller := NewKubernetesController(logger, mockService, lib.Database{})
router.GET("/api/kubernetes/status", controller.GetStatus)
@@ -44,7 +45,7 @@ func TestKubernetesController_GetCurrentContext(t *testing.T) {
return "test-context"
}
- controller := NewKubernetesController(logger, mockService)
+ controller := NewKubernetesController(logger, mockService, lib.Database{})
router.GET("/api/kubernetes/context", controller.GetCurrentContext)
@@ -76,7 +77,7 @@ func TestKubernetesController_GetContexts_Success(t *testing.T) {
return expectedContexts, nil
}
- controller := NewKubernetesController(logger, mockService)
+ controller := NewKubernetesController(logger, mockService, lib.Database{})
router.GET("/api/kubernetes/contexts", controller.GetContexts)
@@ -107,7 +108,7 @@ func TestKubernetesController_GetContexts_Error(t *testing.T) {
return nil, http.ErrMissingFile
}
- controller := NewKubernetesController(logger, mockService)
+ controller := NewKubernetesController(logger, mockService, lib.Database{})
router.GET("/api/kubernetes/contexts", controller.GetContexts)
@@ -133,7 +134,7 @@ func TestKubernetesController_SetContext_Success(t *testing.T) {
return "test-context"
}
- controller := NewKubernetesController(logger, mockService)
+ controller := NewKubernetesController(logger, mockService, lib.Database{})
router.POST("/api/kubernetes/context", controller.SetContext)
@@ -164,7 +165,7 @@ func TestKubernetesController_SetContext_Error(t *testing.T) {
return http.ErrMissingFile
}
- controller := NewKubernetesController(logger, mockService)
+ controller := NewKubernetesController(logger, mockService, lib.Database{})
router.POST("/api/kubernetes/context", controller.SetContext)
@@ -186,7 +187,7 @@ func TestKubernetesController_CheckConnection_WithContext(t *testing.T) {
return true, nil
}
- controller := NewKubernetesController(logger, mockService)
+ controller := NewKubernetesController(logger, mockService, lib.Database{})
router.GET("/api/kubernetes/connection", controller.CheckConnection)
@@ -217,7 +218,7 @@ func TestKubernetesController_CheckConnection_NoContext(t *testing.T) {
return ""
}
- controller := NewKubernetesController(logger, mockService)
+ controller := NewKubernetesController(logger, mockService, lib.Database{})
router.GET("/api/kubernetes/connection", controller.CheckConnection)
@@ -235,7 +236,7 @@ func TestKubernetesController_GetResources_MissingApiVersion(t *testing.T) {
logger := setupTestLogger()
mockService := setupMockKubernetesService()
- controller := NewKubernetesController(logger, mockService)
+ controller := NewKubernetesController(logger, mockService, lib.Database{})
router.GET("/api/resources", controller.GetResources)
@@ -253,7 +254,7 @@ func TestKubernetesController_GetResources_MissingKind(t *testing.T) {
logger := setupTestLogger()
mockService := setupMockKubernetesService()
- controller := NewKubernetesController(logger, mockService)
+ controller := NewKubernetesController(logger, mockService, lib.Database{})
router.GET("/api/resources", controller.GetResources)
@@ -281,7 +282,7 @@ func TestKubernetesController_GetResources_Success(t *testing.T) {
return expectedResult, nil
}
- controller := NewKubernetesController(logger, mockService)
+ controller := NewKubernetesController(logger, mockService, lib.Database{})
router.GET("/api/resources", controller.GetResources)
@@ -303,7 +304,7 @@ func TestKubernetesController_GetResources_NotFound(t *testing.T) {
return nil, fmt.Errorf("404 Not Found")
}
- controller := NewKubernetesController(logger, mockService)
+ controller := NewKubernetesController(logger, mockService, lib.Database{})
router.GET("/api/resources", controller.GetResources)
@@ -325,7 +326,7 @@ func TestKubernetesController_GetResources_MissingApiResource(t *testing.T) {
return nil, fmt.Errorf("failed to list resources: the server could not find the requested resource")
}
- controller := NewKubernetesController(logger, mockService)
+ controller := NewKubernetesController(logger, mockService, lib.Database{})
router.GET("/api/resources", controller.GetResources)
@@ -357,7 +358,7 @@ func TestKubernetesController_GetResource_MissingApiVersion(t *testing.T) {
logger := setupTestLogger()
mockService := setupMockKubernetesService()
- controller := NewKubernetesController(logger, mockService)
+ controller := NewKubernetesController(logger, mockService, lib.Database{})
router.GET("/api/resource", controller.GetResource)
@@ -375,7 +376,7 @@ func TestKubernetesController_GetResource_MissingKind(t *testing.T) {
logger := setupTestLogger()
mockService := setupMockKubernetesService()
- controller := NewKubernetesController(logger, mockService)
+ controller := NewKubernetesController(logger, mockService, lib.Database{})
router.GET("/api/resource", controller.GetResource)
@@ -393,7 +394,7 @@ func TestKubernetesController_GetResource_MissingName(t *testing.T) {
logger := setupTestLogger()
mockService := setupMockKubernetesService()
- controller := NewKubernetesController(logger, mockService)
+ controller := NewKubernetesController(logger, mockService, lib.Database{})
router.GET("/api/resource", controller.GetResource)
@@ -423,7 +424,7 @@ func TestKubernetesController_GetResource_Success(t *testing.T) {
return expectedResource, nil
}
- controller := NewKubernetesController(logger, mockService)
+ controller := NewKubernetesController(logger, mockService, lib.Database{})
router.GET("/api/resource", controller.GetResource)
@@ -445,7 +446,7 @@ func TestKubernetesController_GetResource_NotFound(t *testing.T) {
return nil, fmt.Errorf("resource not found: Pod/test-pod")
}
- controller := NewKubernetesController(logger, mockService)
+ controller := NewKubernetesController(logger, mockService, lib.Database{})
router.GET("/api/resource", controller.GetResource)
@@ -463,7 +464,7 @@ func TestKubernetesController_GetEvents_MissingKind(t *testing.T) {
logger := setupTestLogger()
mockService := setupMockKubernetesService()
- controller := NewKubernetesController(logger, mockService)
+ controller := NewKubernetesController(logger, mockService, lib.Database{})
router.GET("/api/events", controller.GetEvents)
@@ -481,7 +482,7 @@ func TestKubernetesController_GetEvents_MissingName(t *testing.T) {
logger := setupTestLogger()
mockService := setupMockKubernetesService()
- controller := NewKubernetesController(logger, mockService)
+ controller := NewKubernetesController(logger, mockService, lib.Database{})
router.GET("/api/events", controller.GetEvents)
@@ -510,7 +511,7 @@ func TestKubernetesController_GetEvents_Success(t *testing.T) {
return expectedEvents, nil
}
- controller := NewKubernetesController(logger, mockService)
+ controller := NewKubernetesController(logger, mockService, lib.Database{})
router.GET("/api/events", controller.GetEvents)
@@ -532,7 +533,7 @@ func TestKubernetesController_GetEvents_Error(t *testing.T) {
return nil, http.ErrMissingFile
}
- controller := NewKubernetesController(logger, mockService)
+ controller := NewKubernetesController(logger, mockService, lib.Database{})
router.GET("/api/events", controller.GetEvents)
@@ -568,7 +569,7 @@ func TestKubernetesController_GetManagedResources_Success(t *testing.T) {
return expectedResult, nil
}
- controller := NewKubernetesController(logger, mockService)
+ controller := NewKubernetesController(logger, mockService, lib.Database{})
router.GET("/api/managed", controller.GetManagedResources)
@@ -590,7 +591,7 @@ func TestKubernetesController_GetManagedResources_Error(t *testing.T) {
return nil, http.ErrMissingFile
}
- controller := NewKubernetesController(logger, mockService)
+ controller := NewKubernetesController(logger, mockService, lib.Database{})
router.GET("/api/managed", controller.GetManagedResources)
diff --git a/crossview-go-server/api/controllers/kubernetes/kubernetes_test_helpers.go b/crossview-go-server/api/controllers/kubernetes/kubernetes_test_helpers.go
index 20345853..8248d5a3 100644
--- a/crossview-go-server/api/controllers/kubernetes/kubernetes_test_helpers.go
+++ b/crossview-go-server/api/controllers/kubernetes/kubernetes_test_helpers.go
@@ -1,8 +1,10 @@
package kubernetes
import (
- "github.com/gin-gonic/gin"
"crossview-go-server/lib"
+ "crossview-go-server/services"
+
+ "github.com/gin-gonic/gin"
"k8s.io/client-go/kubernetes"
"k8s.io/client-go/rest"
)
@@ -35,6 +37,8 @@ type MockKubernetesService struct {
GetResourceFunc func(apiVersion, kind, name, namespace, contextName, plural string) (map[string]interface{}, error)
GetEventsFunc func(kind, name, namespace, contextName string) ([]map[string]interface{}, error)
GetManagedResourcesFunc func(contextName string, forceRefresh bool) (map[string]interface{}, error)
+ GetResourceTreeFunc func(apiVersion, kind, name, namespace, contextName string, maxDepth int) (*services.TreeNode, error)
+ GetResourceDriftFunc func(apiVersion, kind, name, namespace, contextName string) (map[string]interface{}, error)
}
func (m MockKubernetesService) SetContext(ctxName string) error {
@@ -133,3 +137,17 @@ func (m MockKubernetesService) ClearManagedResourcesCache(contextName string) {
}
}
+func (m MockKubernetesService) GetResourceTree(apiVersion, kind, name, namespace, contextName string, maxDepth int) (*services.TreeNode, error) {
+ if m.GetResourceTreeFunc != nil {
+ return m.GetResourceTreeFunc(apiVersion, kind, name, namespace, contextName, maxDepth)
+ }
+ return nil, nil
+}
+
+func (m MockKubernetesService) GetResourceDrift(apiVersion, kind, name, namespace, contextName string) (map[string]interface{}, error) {
+ if m.GetResourceDriftFunc != nil {
+ return m.GetResourceDriftFunc(apiVersion, kind, name, namespace, contextName)
+ }
+ return map[string]interface{}{"hasDrift": false}, nil
+}
+
diff --git a/crossview-go-server/api/routes/kubernetes.go b/crossview-go-server/api/routes/kubernetes.go
index faedcd94..32d609bf 100644
--- a/crossview-go-server/api/routes/kubernetes.go
+++ b/crossview-go-server/api/routes/kubernetes.go
@@ -50,6 +50,12 @@ func (r KubernetesRoutes) Setup() {
api.GET("/resource", r.authMiddleware.Handler(), r.controller.GetResource)
api.GET("/events", r.authMiddleware.Handler(), r.controller.GetEvents)
api.GET("/managed", r.authMiddleware.Handler(), r.controller.GetManagedResources)
+ api.GET("/resource/tree", r.authMiddleware.Handler(), r.controller.GetResourceTree)
+ api.GET("/resource/drift", r.authMiddleware.Handler(), r.controller.GetResourceDrift)
+ api.GET("/resource/history", r.authMiddleware.Handler(), r.controller.GetResourceHistory)
+ api.GET("/history/recent", r.authMiddleware.Handler(), r.controller.GetRecentHistory)
+ api.GET("/metrics/summary", r.authMiddleware.Handler(), r.controller.GetMetricsSummary)
+ api.GET("/metrics/health-trend", r.authMiddleware.Handler(), r.controller.GetHealthTrend)
api.GET("/watch", r.authMiddleware.Handler(), r.watchController.WatchResources)
}
}
diff --git a/crossview-go-server/commands/serve.go b/crossview-go-server/commands/serve.go
index db5dac2f..65f19e5f 100644
--- a/crossview-go-server/commands/serve.go
+++ b/crossview-go-server/commands/serve.go
@@ -5,6 +5,7 @@ import (
"crossview-go-server/api/routes"
"crossview-go-server/lib"
"crossview-go-server/models"
+ "crossview-go-server/services"
"github.com/spf13/cobra"
"golang.org/x/crypto/bcrypt"
@@ -27,6 +28,7 @@ func (s *ServeCommand) Run() lib.CommandRunner {
route routes.Routes,
logger lib.Logger,
database lib.Database,
+ k8sService services.KubernetesServiceInterface,
) {
logger.Info("Starting server initialization...")
@@ -60,6 +62,15 @@ func (s *ServeCommand) Run() lib.CommandRunner {
logger.Panicf("Failed to run database migrations: %v", err)
}
logger.Info("Database migrations completed successfully")
+
+ eventRepo := models.NewResourceEventRepository(database.DB)
+ if err := eventRepo.AutoMigrate(); err != nil {
+ logger.Warnf("Failed to migrate resource_events table: %v", err)
+ }
+ metricRepo := models.NewResourceMetricRepository(database.DB)
+ if err := metricRepo.AutoMigrate(); err != nil {
+ logger.Warnf("Failed to migrate resource_metrics table: %v", err)
+ }
if env.AuthMode == "session" && env.DBEnabled {
hasAdmin, err := userRepo.HasAdmin()
if err != nil {
@@ -77,6 +88,12 @@ func (s *ServeCommand) Run() lib.CommandRunner {
}
+ if database.DB != nil {
+ metricRepoForCollector := models.NewResourceMetricRepository(database.DB)
+ collector := services.NewMetricsCollector(logger, k8sService, metricRepoForCollector, 300, 90)
+ collector.Start()
+ }
+
middleware.Setup()
route.Setup()
diff --git a/crossview-go-server/models/resource_event.go b/crossview-go-server/models/resource_event.go
new file mode 100644
index 00000000..67ad4186
--- /dev/null
+++ b/crossview-go-server/models/resource_event.go
@@ -0,0 +1,86 @@
+package models
+
+import (
+ "fmt"
+ "time"
+
+ "gorm.io/gorm"
+)
+
+type ResourceEvent struct {
+ ID uint `gorm:"primaryKey" json:"id"`
+ Context string `gorm:"index;not null" json:"context"`
+ APIVersion string `gorm:"column:api_version;not null" json:"apiVersion"`
+ Kind string `gorm:"index;not null" json:"kind"`
+ Name string `gorm:"index;not null" json:"name"`
+ Namespace string `json:"namespace,omitempty"`
+ EventType string `gorm:"not null" json:"eventType"`
+ Snapshot string `gorm:"type:text" json:"snapshot,omitempty"`
+ CreatedAt time.Time `gorm:"index" json:"createdAt"`
+}
+
+func (ResourceEvent) TableName() string {
+ return "resource_events"
+}
+
+type ResourceEventRepository struct {
+ db *gorm.DB
+}
+
+func NewResourceEventRepository(db *gorm.DB) *ResourceEventRepository {
+ return &ResourceEventRepository{db: db}
+}
+
+func (r *ResourceEventRepository) Create(event *ResourceEvent) error {
+ if r.db == nil {
+ return nil
+ }
+ return r.db.Create(event).Error
+}
+
+func (r *ResourceEventRepository) FindByResource(apiVersion, kind, name, namespace, contextName string, limit int) ([]ResourceEvent, error) {
+ if r.db == nil {
+ return nil, nil
+ }
+ var events []ResourceEvent
+ query := r.db.Where("api_version = ? AND kind = ? AND name = ? AND context = ?", apiVersion, kind, name, contextName)
+ if namespace != "" {
+ query = query.Where("namespace = ?", namespace)
+ }
+ err := query.Order("created_at DESC").Limit(limit).Find(&events).Error
+ return events, err
+}
+
+func (r *ResourceEventRepository) FindRecent(contextName string, limit int) ([]ResourceEvent, error) {
+ if r.db == nil {
+ return nil, nil
+ }
+ var events []ResourceEvent
+ query := r.db
+ if contextName != "" {
+ query = query.Where("context = ?", contextName)
+ }
+ err := query.Order("created_at DESC").Limit(limit).Find(&events).Error
+ return events, err
+}
+
+func (r *ResourceEventRepository) DeleteOlderThan(before time.Time) error {
+ if r.db == nil {
+ return nil
+ }
+ return r.db.Where("created_at < ?", before).Delete(&ResourceEvent{}).Error
+}
+
+func (r *ResourceEventRepository) AutoMigrate() error {
+ if r.db == nil {
+ return nil
+ }
+ sqlDB, err := r.db.DB()
+ if err != nil {
+ return fmt.Errorf("failed to get underlying SQL DB: %w", err)
+ }
+ if err := sqlDB.Ping(); err != nil {
+ return fmt.Errorf("database ping failed: %w", err)
+ }
+ return r.db.AutoMigrate(&ResourceEvent{})
+}
diff --git a/crossview-go-server/models/resource_metric.go b/crossview-go-server/models/resource_metric.go
new file mode 100644
index 00000000..5cdbf540
--- /dev/null
+++ b/crossview-go-server/models/resource_metric.go
@@ -0,0 +1,83 @@
+package models
+
+import (
+ "fmt"
+ "time"
+
+ "gorm.io/gorm"
+)
+
+type ResourceMetric struct {
+ ID uint `gorm:"primaryKey" json:"id"`
+ Context string `gorm:"index;not null" json:"context"`
+ Timestamp time.Time `gorm:"index;not null" json:"timestamp"`
+ TotalResources int `json:"totalResources"`
+ HealthyCount int `json:"healthyCount"`
+ DegradedCount int `json:"degradedCount"`
+ SyncedCount int `json:"syncedCount"`
+ UnsyncedCount int `json:"unsyncedCount"`
+ ResourcesByKind string `gorm:"type:text" json:"resourcesByKind"`
+ ProviderHealth string `gorm:"type:text" json:"providerHealth"`
+}
+
+func (ResourceMetric) TableName() string {
+ return "resource_metrics"
+}
+
+type ResourceMetricRepository struct {
+ db *gorm.DB
+}
+
+func NewResourceMetricRepository(db *gorm.DB) *ResourceMetricRepository {
+ return &ResourceMetricRepository{db: db}
+}
+
+func (r *ResourceMetricRepository) Create(metric *ResourceMetric) error {
+ if r.db == nil {
+ return nil
+ }
+ return r.db.Create(metric).Error
+}
+
+func (r *ResourceMetricRepository) FindByTimeRange(contextName string, from, to time.Time, limit int) ([]ResourceMetric, error) {
+ if r.db == nil {
+ return nil, nil
+ }
+ var metrics []ResourceMetric
+ query := r.db.Where("context = ? AND timestamp BETWEEN ? AND ?", contextName, from, to)
+ err := query.Order("timestamp ASC").Limit(limit).Find(&metrics).Error
+ return metrics, err
+}
+
+func (r *ResourceMetricRepository) FindLatest(contextName string) (*ResourceMetric, error) {
+ if r.db == nil {
+ return nil, nil
+ }
+ var metric ResourceMetric
+ err := r.db.Where("context = ?", contextName).Order("timestamp DESC").First(&metric).Error
+ if err != nil {
+ return nil, err
+ }
+ return &metric, nil
+}
+
+func (r *ResourceMetricRepository) DeleteOlderThan(before time.Time) error {
+ if r.db == nil {
+ return nil
+ }
+ return r.db.Where("timestamp < ?", before).Delete(&ResourceMetric{}).Error
+}
+
+func (r *ResourceMetricRepository) AutoMigrate() error {
+ if r.db == nil {
+ return nil
+ }
+ sqlDB, err := r.db.DB()
+ if err != nil {
+ return fmt.Errorf("failed to get underlying SQL DB: %w", err)
+ }
+ if err := sqlDB.Ping(); err != nil {
+ return fmt.Errorf("database ping failed: %w", err)
+ }
+ return r.db.AutoMigrate(&ResourceMetric{})
+}
diff --git a/crossview-go-server/services/kubernetes_drift.go b/crossview-go-server/services/kubernetes_drift.go
new file mode 100644
index 00000000..a329f36a
--- /dev/null
+++ b/crossview-go-server/services/kubernetes_drift.go
@@ -0,0 +1,151 @@
+package services
+
+import (
+ "fmt"
+ "reflect"
+ "strings"
+)
+
+type FieldDiff struct {
+ Path string `json:"path"`
+ Desired interface{} `json:"desired"`
+ Actual interface{} `json:"actual"`
+}
+
+func (k *KubernetesService) GetResourceDrift(apiVersion, kind, name, namespace, contextName string) (map[string]interface{}, error) {
+ if contextName != "" {
+ if err := k.SetContext(contextName); err != nil {
+ return nil, fmt.Errorf("failed to set context: %w", err)
+ }
+ }
+
+ resource, err := k.GetResource(apiVersion, kind, name, namespace, contextName, "")
+ if err != nil {
+ return nil, fmt.Errorf("failed to get resource: %w", err)
+ }
+
+ result := map[string]interface{}{
+ "hasDrift": false,
+ "syncedCondition": nil,
+ "readyCondition": nil,
+ "fieldDiffs": []FieldDiff{},
+ "driftSummary": "No drift detected",
+ }
+
+ status, _ := resource["status"].(map[string]interface{})
+ if status == nil {
+ result["driftSummary"] = "No status available"
+ return result, nil
+ }
+
+ conditions, _ := status["conditions"].([]interface{})
+ var syncedCondition map[string]interface{}
+ var readyCondition map[string]interface{}
+
+ for _, c := range conditions {
+ cond, ok := c.(map[string]interface{})
+ if !ok {
+ continue
+ }
+ condType, _ := cond["type"].(string)
+ if condType == "Synced" {
+ syncedCondition = cond
+ }
+ if condType == "Ready" {
+ readyCondition = cond
+ }
+ }
+
+ if syncedCondition != nil {
+ result["syncedCondition"] = syncedCondition
+ }
+ if readyCondition != nil {
+ result["readyCondition"] = readyCondition
+ }
+
+ hasDrift := false
+ var driftReasons []string
+
+ if syncedCondition != nil {
+ syncedStatus, _ := syncedCondition["status"].(string)
+ if syncedStatus == "False" {
+ hasDrift = true
+ reason, _ := syncedCondition["reason"].(string)
+ message, _ := syncedCondition["message"].(string)
+ driftReasons = append(driftReasons, fmt.Sprintf("Not synced: %s - %s", reason, message))
+ }
+ }
+
+ if readyCondition != nil {
+ readyStatus, _ := readyCondition["status"].(string)
+ if readyStatus == "False" {
+ reason, _ := readyCondition["reason"].(string)
+ message, _ := readyCondition["message"].(string)
+ driftReasons = append(driftReasons, fmt.Sprintf("Not ready: %s - %s", reason, message))
+ }
+ }
+
+ spec, _ := resource["spec"].(map[string]interface{})
+ fieldDiffs := compareSpecAndStatus(spec, status)
+ if len(fieldDiffs) > 0 {
+ hasDrift = true
+ result["fieldDiffs"] = fieldDiffs
+ driftReasons = append(driftReasons, fmt.Sprintf("%d field(s) differ between desired and actual state", len(fieldDiffs)))
+ }
+
+ result["hasDrift"] = hasDrift
+ if hasDrift {
+ result["driftSummary"] = strings.Join(driftReasons, "; ")
+ }
+
+ return result, nil
+}
+
+func compareSpecAndStatus(spec, status map[string]interface{}) []FieldDiff {
+ if spec == nil || status == nil {
+ return nil
+ }
+
+ forProvider, _ := spec["forProvider"].(map[string]interface{})
+ atProvider, _ := status["atProvider"].(map[string]interface{})
+
+ if forProvider == nil || atProvider == nil {
+ return nil
+ }
+
+ var diffs []FieldDiff
+ diffMaps(forProvider, atProvider, "spec.forProvider", &diffs)
+ return diffs
+}
+
+func diffMaps(desired, actual map[string]interface{}, prefix string, diffs *[]FieldDiff) {
+ for key, desiredVal := range desired {
+ path := prefix + "." + key
+ actualVal, exists := actual[key]
+
+ if !exists {
+ *diffs = append(*diffs, FieldDiff{
+ Path: path,
+ Desired: desiredVal,
+ Actual: nil,
+ })
+ continue
+ }
+
+ desiredMap, desiredIsMap := desiredVal.(map[string]interface{})
+ actualMap, actualIsMap := actualVal.(map[string]interface{})
+
+ if desiredIsMap && actualIsMap {
+ diffMaps(desiredMap, actualMap, path, diffs)
+ continue
+ }
+
+ if !reflect.DeepEqual(desiredVal, actualVal) {
+ *diffs = append(*diffs, FieldDiff{
+ Path: path,
+ Desired: desiredVal,
+ Actual: actualVal,
+ })
+ }
+ }
+}
diff --git a/crossview-go-server/services/kubernetes_service.go b/crossview-go-server/services/kubernetes_service.go
index 783efd2e..a08472fa 100644
--- a/crossview-go-server/services/kubernetes_service.go
+++ b/crossview-go-server/services/kubernetes_service.go
@@ -28,6 +28,8 @@ type KubernetesServiceInterface interface {
GetResource(apiVersion, kind, name, namespace, contextName, plural string) (map[string]interface{}, error)
GetEvents(kind, name, namespace, contextName string) ([]map[string]interface{}, error)
GetManagedResources(contextName string, forceRefresh bool) (map[string]interface{}, error)
+ GetResourceTree(apiVersion, kind, name, namespace, contextName string, maxDepth int) (*TreeNode, error)
+ GetResourceDrift(apiVersion, kind, name, namespace, contextName string) (map[string]interface{}, error)
}
type KubernetesService struct {
diff --git a/crossview-go-server/services/kubernetes_tree.go b/crossview-go-server/services/kubernetes_tree.go
new file mode 100644
index 00000000..ba963124
--- /dev/null
+++ b/crossview-go-server/services/kubernetes_tree.go
@@ -0,0 +1,210 @@
+package services
+
+import (
+ "fmt"
+
+ "k8s.io/client-go/dynamic"
+)
+
+type TreeNode struct {
+ APIVersion string `json:"apiVersion"`
+ Kind string `json:"kind"`
+ Name string `json:"name"`
+ Namespace string `json:"namespace,omitempty"`
+ Status string `json:"status"`
+ Resource map[string]interface{} `json:"resource,omitempty"`
+ Children []*TreeNode `json:"children,omitempty"`
+ RelType string `json:"relType,omitempty"`
+}
+
+func (k *KubernetesService) GetResourceTree(apiVersion, kind, name, namespace, contextName string, maxDepth int) (*TreeNode, error) {
+ if contextName != "" {
+ if err := k.SetContext(contextName); err != nil {
+ return nil, fmt.Errorf("failed to set context: %w", err)
+ }
+ }
+
+ config, err := k.GetConfig()
+ if err != nil {
+ return nil, fmt.Errorf("failed to get kubernetes config: %w", err)
+ }
+
+ dynClient, err := dynamic.NewForConfig(config)
+ if err != nil {
+ return nil, fmt.Errorf("failed to create dynamic client: %w", err)
+ }
+
+ if maxDepth <= 0 {
+ maxDepth = 5
+ }
+
+ visited := make(map[string]bool)
+ root, err := k.buildTreeNode(dynClient, apiVersion, kind, name, namespace, contextName, "", maxDepth, visited)
+ if err != nil {
+ return nil, err
+ }
+
+ return root, nil
+}
+
+func (k *KubernetesService) buildTreeNode(dynClient dynamic.Interface, apiVersion, kind, name, namespace, contextName, relType string, depth int, visited map[string]bool) (*TreeNode, error) {
+ nodeKey := fmt.Sprintf("%s/%s/%s/%s", apiVersion, kind, namespace, name)
+ if visited[nodeKey] || depth <= 0 {
+ return &TreeNode{
+ APIVersion: apiVersion,
+ Kind: kind,
+ Name: name,
+ Namespace: namespace,
+ RelType: relType,
+ Status: "unknown",
+ }, nil
+ }
+ visited[nodeKey] = true
+
+ resource, err := k.GetResource(apiVersion, kind, name, namespace, contextName, "")
+ if err != nil {
+ return &TreeNode{
+ APIVersion: apiVersion,
+ Kind: kind,
+ Name: name,
+ Namespace: namespace,
+ RelType: relType,
+ Status: "error",
+ }, nil
+ }
+
+ node := &TreeNode{
+ APIVersion: apiVersion,
+ Kind: kind,
+ Name: name,
+ Namespace: namespace,
+ RelType: relType,
+ Status: extractStatus(resource),
+ Resource: resource,
+ }
+
+ spec, _ := resource["spec"].(map[string]interface{})
+ if spec == nil {
+ return node, nil
+ }
+
+ if resourceRef, ok := spec["resourceRef"].(map[string]interface{}); ok {
+ child, err := k.resolveRef(dynClient, resourceRef, contextName, "compositeResource", depth-1, visited)
+ if err == nil && child != nil {
+ node.Children = append(node.Children, child)
+ }
+ }
+
+ if resourceRefs, ok := spec["resourceRefs"].([]interface{}); ok {
+ for _, ref := range resourceRefs {
+ refMap, ok := ref.(map[string]interface{})
+ if !ok {
+ continue
+ }
+ child, err := k.resolveRef(dynClient, refMap, contextName, "managedResource", depth-1, visited)
+ if err == nil && child != nil {
+ node.Children = append(node.Children, child)
+ }
+ }
+ }
+
+ if compositionRef, ok := spec["compositionRef"].(map[string]interface{}); ok {
+ compName, _ := compositionRef["name"].(string)
+ if compName != "" {
+ child, err := k.buildTreeNode(dynClient, "apiextensions.crossplane.io/v1", "Composition", compName, "", contextName, "composition", depth-1, visited)
+ if err == nil && child != nil {
+ node.Children = append(node.Children, child)
+ }
+ }
+ }
+
+ if claimRef, ok := spec["claimRef"].(map[string]interface{}); ok {
+ child, err := k.resolveRef(dynClient, claimRef, contextName, "claim", depth-1, visited)
+ if err == nil && child != nil {
+ node.Children = append(node.Children, child)
+ }
+ }
+
+ return node, nil
+}
+
+func (k *KubernetesService) resolveRef(dynClient dynamic.Interface, ref map[string]interface{}, contextName, relType string, depth int, visited map[string]bool) (*TreeNode, error) {
+ refName, _ := ref["name"].(string)
+ refNamespace, _ := ref["namespace"].(string)
+ refKind, _ := ref["kind"].(string)
+ refAPIVersion, _ := ref["apiVersion"].(string)
+
+ if refName == "" || refKind == "" {
+ return nil, fmt.Errorf("incomplete ref")
+ }
+
+ if refAPIVersion == "" {
+ refAPIVersion = guessAPIVersion(refKind)
+ }
+
+ return k.buildTreeNode(dynClient, refAPIVersion, refKind, refName, refNamespace, contextName, relType, depth, visited)
+}
+
+func extractStatus(resource map[string]interface{}) string {
+ status, ok := resource["status"].(map[string]interface{})
+ if !ok {
+ return "unknown"
+ }
+
+ conditions, ok := status["conditions"].([]interface{})
+ if !ok {
+ return "unknown"
+ }
+
+ synced := false
+ ready := false
+
+ for _, c := range conditions {
+ cond, ok := c.(map[string]interface{})
+ if !ok {
+ continue
+ }
+ condType, _ := cond["type"].(string)
+ condStatus, _ := cond["status"].(string)
+
+ if condType == "Synced" && condStatus == "True" {
+ synced = true
+ }
+ if condType == "Ready" && condStatus == "True" {
+ ready = true
+ }
+ if condType == "Healthy" && condStatus == "True" {
+ return "healthy"
+ }
+ }
+
+ if synced && ready {
+ return "healthy"
+ }
+ if synced || ready {
+ return "degraded"
+ }
+
+ if len(conditions) > 0 {
+ return "degraded"
+ }
+
+ return "unknown"
+}
+
+func guessAPIVersion(kind string) string {
+ crossplaneTypes := map[string]string{
+ "Composition": "apiextensions.crossplane.io/v1",
+ "CompositeResourceDefinition": "apiextensions.crossplane.io/v1",
+ "Provider": "pkg.crossplane.io/v1",
+ "Function": "pkg.crossplane.io/v1",
+ "ProviderConfig": "pkg.crossplane.io/v1",
+ }
+
+ if av, ok := crossplaneTypes[kind]; ok {
+ return av
+ }
+
+ return ""
+}
+
diff --git a/crossview-go-server/services/metrics_collector.go b/crossview-go-server/services/metrics_collector.go
new file mode 100644
index 00000000..5c39d70f
--- /dev/null
+++ b/crossview-go-server/services/metrics_collector.go
@@ -0,0 +1,149 @@
+package services
+
+import (
+ "encoding/json"
+ "time"
+
+ "crossview-go-server/lib"
+ "crossview-go-server/models"
+)
+
+type MetricsCollector struct {
+ logger lib.Logger
+ k8sService KubernetesServiceInterface
+ metricRepo *models.ResourceMetricRepository
+ interval time.Duration
+ retention time.Duration
+ stopCh chan struct{}
+}
+
+func NewMetricsCollector(
+ logger lib.Logger,
+ k8sService KubernetesServiceInterface,
+ metricRepo *models.ResourceMetricRepository,
+ intervalSeconds int,
+ retentionDays int,
+) *MetricsCollector {
+ if intervalSeconds <= 0 {
+ intervalSeconds = 300
+ }
+ if retentionDays <= 0 {
+ retentionDays = 90
+ }
+
+ return &MetricsCollector{
+ logger: logger,
+ k8sService: k8sService,
+ metricRepo: metricRepo,
+ interval: time.Duration(intervalSeconds) * time.Second,
+ retention: time.Duration(retentionDays) * 24 * time.Hour,
+ stopCh: make(chan struct{}),
+ }
+}
+
+func (mc *MetricsCollector) Start() {
+ mc.logger.Info("Starting metrics collector")
+ go mc.run()
+}
+
+func (mc *MetricsCollector) Stop() {
+ close(mc.stopCh)
+}
+
+func (mc *MetricsCollector) run() {
+ ticker := time.NewTicker(mc.interval)
+ defer ticker.Stop()
+
+ mc.collect()
+
+ for {
+ select {
+ case <-ticker.C:
+ mc.collect()
+ case <-mc.stopCh:
+ mc.logger.Info("Stopping metrics collector")
+ return
+ }
+ }
+}
+
+func (mc *MetricsCollector) collect() {
+ contextName := mc.k8sService.GetCurrentContext()
+ if contextName == "" {
+ return
+ }
+
+ metric := &models.ResourceMetric{
+ Context: contextName,
+ Timestamp: time.Now(),
+ }
+
+ resourceTypes := []struct {
+ apiVersion string
+ kind string
+ }{
+ {"apiextensions.crossplane.io/v1", "Composition"},
+ {"apiextensions.crossplane.io/v1", "CompositeResourceDefinition"},
+ {"pkg.crossplane.io/v1", "Provider"},
+ {"pkg.crossplane.io/v1", "Function"},
+ }
+
+ kindCounts := make(map[string]int)
+ providerHealth := make(map[string]string)
+
+ for _, rt := range resourceTypes {
+ result, err := mc.k8sService.GetResources(rt.apiVersion, rt.kind, "", contextName, "", nil, "")
+ if err != nil {
+ continue
+ }
+
+ items, _ := result["items"].([]interface{})
+ kindCounts[rt.kind] = len(items)
+ metric.TotalResources += len(items)
+
+ for _, item := range items {
+ resource, ok := item.(map[string]interface{})
+ if !ok {
+ continue
+ }
+
+ status := extractStatus(resource)
+ switch status {
+ case "healthy":
+ metric.HealthyCount++
+ metric.SyncedCount++
+ case "degraded":
+ metric.DegradedCount++
+ metric.UnsyncedCount++
+ default:
+ metric.DegradedCount++
+ }
+
+ if rt.kind == "Provider" {
+ metadata, _ := resource["metadata"].(map[string]interface{})
+ name, _ := metadata["name"].(string)
+ if name != "" {
+ providerHealth[name] = status
+ }
+ }
+ }
+ }
+
+ kindJSON, _ := json.Marshal(kindCounts)
+ metric.ResourcesByKind = string(kindJSON)
+
+ providerJSON, _ := json.Marshal(providerHealth)
+ metric.ProviderHealth = string(providerJSON)
+
+ if err := mc.metricRepo.Create(metric); err != nil {
+ mc.logger.Errorf("Failed to store metric: %s", err.Error())
+ return
+ }
+
+ cutoff := time.Now().Add(-mc.retention)
+ if err := mc.metricRepo.DeleteOlderThan(cutoff); err != nil {
+ mc.logger.Warnf("Failed to clean old metrics: %s", err.Error())
+ }
+
+ mc.logger.Infof("Collected metrics: total=%d healthy=%d degraded=%d", metric.TotalResources, metric.HealthyCount, metric.DegradedCount)
+}
diff --git a/package-lock.json b/package-lock.json
index ff421fef..701d56c8 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -12,6 +12,7 @@
"@chakra-ui/react": "^3.30.0",
"@codemirror/lang-yaml": "^6.1.2",
"@codemirror/search": "^6.6.0",
+ "@dagrejs/dagre": "^1.1.4",
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1",
"@lezer/highlight": "^1.2.3",
@@ -26,6 +27,7 @@
"react-icons": "^5.5.0",
"react-router-dom": "^7.9.6",
"react-syntax-highlighter": "^16.1.0",
+ "recharts": "^2.15.3",
"thememirror": "^2.0.1",
"yaml": "^2.8.2"
},
@@ -576,6 +578,24 @@
"node": ">=10"
}
},
+ "node_modules/@dagrejs/dagre": {
+ "version": "1.1.8",
+ "resolved": "https://registry.npmjs.org/@dagrejs/dagre/-/dagre-1.1.8.tgz",
+ "integrity": "sha512-5SEDlndt4W/LaVzPYJW+bSmSEZc9EzTf8rJ20WCKvjS5EAZAN0b+x0Yww7VMT4R3Wootkg+X9bUfUxazYw6Blw==",
+ "license": "MIT",
+ "dependencies": {
+ "@dagrejs/graphlib": "2.2.4"
+ }
+ },
+ "node_modules/@dagrejs/graphlib": {
+ "version": "2.2.4",
+ "resolved": "https://registry.npmjs.org/@dagrejs/graphlib/-/graphlib-2.2.4.tgz",
+ "integrity": "sha512-mepCf/e9+SKYy1d02/UkvSy6+6MoyXhVxP8lLDfA7BPE1X1d4dR0sZznmbM8/XVJ1GPM+Svnx7Xj6ZweByWUkw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">17.0.0"
+ }
+ },
"node_modules/@emotion/babel-plugin": {
"version": "11.13.5",
"resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz",
@@ -1912,6 +1932,12 @@
"@babel/types": "^7.28.2"
}
},
+ "node_modules/@types/d3-array": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
+ "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
+ "license": "MIT"
+ },
"node_modules/@types/d3-color": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
@@ -1927,6 +1953,12 @@
"@types/d3-selection": "*"
}
},
+ "node_modules/@types/d3-ease": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
+ "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
+ "license": "MIT"
+ },
"node_modules/@types/d3-interpolate": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
@@ -1936,12 +1968,48 @@
"@types/d3-color": "*"
}
},
+ "node_modules/@types/d3-path": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
+ "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-scale": {
+ "version": "4.0.9",
+ "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
+ "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-time": "*"
+ }
+ },
"node_modules/@types/d3-selection": {
"version": "3.0.11",
"resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz",
"integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==",
"license": "MIT"
},
+ "node_modules/@types/d3-shape": {
+ "version": "3.1.8",
+ "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz",
+ "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-path": "*"
+ }
+ },
+ "node_modules/@types/d3-time": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
+ "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-timer": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
+ "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
+ "license": "MIT"
+ },
"node_modules/@types/d3-transition": {
"version": "3.0.9",
"resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz",
@@ -3519,6 +3587,15 @@
"integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==",
"license": "MIT"
},
+ "node_modules/clsx": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
+ "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/codemirror": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/codemirror/-/codemirror-6.0.2.tgz",
@@ -3864,6 +3941,18 @@
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"license": "MIT"
},
+ "node_modules/d3-array": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
+ "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
+ "license": "ISC",
+ "dependencies": {
+ "internmap": "1 - 2"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
"node_modules/d3-color": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
@@ -3904,6 +3993,15 @@
"node": ">=12"
}
},
+ "node_modules/d3-format": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
+ "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
"node_modules/d3-interpolate": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
@@ -3916,6 +4014,31 @@
"node": ">=12"
}
},
+ "node_modules/d3-path": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
+ "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-scale": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
+ "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-array": "2.10.0 - 3",
+ "d3-format": "1 - 3",
+ "d3-interpolate": "1.2.0 - 3",
+ "d3-time": "2.1.1 - 3",
+ "d3-time-format": "2 - 4"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
"node_modules/d3-selection": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
@@ -3925,6 +4048,42 @@
"node": ">=12"
}
},
+ "node_modules/d3-shape": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
+ "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-path": "^3.1.0"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-time": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
+ "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-array": "2 - 3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-time-format": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
+ "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-time": "1 - 3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
"node_modules/d3-timer": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
@@ -4040,6 +4199,12 @@
}
}
},
+ "node_modules/decimal.js-light": {
+ "version": "2.5.1",
+ "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
+ "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
+ "license": "MIT"
+ },
"node_modules/decode-named-character-reference": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.2.0.tgz",
@@ -4109,6 +4274,16 @@
"node": ">=6.0.0"
}
},
+ "node_modules/dom-helpers": {
+ "version": "5.2.1",
+ "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz",
+ "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.8.7",
+ "csstype": "^3.0.2"
+ }
+ },
"node_modules/dot-prop": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-5.3.0.tgz",
@@ -4644,6 +4819,12 @@
"node": ">=0.10.0"
}
},
+ "node_modules/eventemitter3": {
+ "version": "4.0.7",
+ "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz",
+ "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==",
+ "license": "MIT"
+ },
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@@ -4651,6 +4832,15 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/fast-equals": {
+ "version": "5.4.0",
+ "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.0.tgz",
+ "integrity": "sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
"node_modules/fast-json-stable-stringify": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
@@ -5377,6 +5567,15 @@
"node": ">= 0.4"
}
},
+ "node_modules/internmap": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
+ "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
"node_modules/is-alphabetical": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz",
@@ -6016,6 +6215,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/lodash": {
+ "version": "4.18.1",
+ "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
+ "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
+ "license": "MIT"
+ },
"node_modules/lodash.merge": {
"version": "4.6.2",
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
@@ -6207,7 +6412,6 @@
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
@@ -6586,7 +6790,6 @@
"version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
- "dev": true,
"license": "MIT",
"dependencies": {
"loose-envify": "^1.4.0",
@@ -6751,6 +6954,21 @@
"url": "https://opencollective.com/express"
}
},
+ "node_modules/react-smooth": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.4.tgz",
+ "integrity": "sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==",
+ "license": "MIT",
+ "dependencies": {
+ "fast-equals": "^5.0.1",
+ "prop-types": "^15.8.1",
+ "react-transition-group": "^4.4.5"
+ },
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
+ "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
"node_modules/react-syntax-highlighter": {
"version": "16.1.0",
"resolved": "https://registry.npmjs.org/react-syntax-highlighter/-/react-syntax-highlighter-16.1.0.tgz",
@@ -6771,6 +6989,22 @@
"react": ">= 0.14.0"
}
},
+ "node_modules/react-transition-group": {
+ "version": "4.4.5",
+ "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz",
+ "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "@babel/runtime": "^7.5.5",
+ "dom-helpers": "^5.0.1",
+ "loose-envify": "^1.4.0",
+ "prop-types": "^15.6.2"
+ },
+ "peerDependencies": {
+ "react": ">=16.6.0",
+ "react-dom": ">=16.6.0"
+ }
+ },
"node_modules/read-package-up": {
"version": "11.0.0",
"resolved": "https://registry.npmjs.org/read-package-up/-/read-package-up-11.0.0.tgz",
@@ -6853,6 +7087,44 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/recharts": {
+ "version": "2.15.4",
+ "resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.4.tgz",
+ "integrity": "sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw==",
+ "license": "MIT",
+ "dependencies": {
+ "clsx": "^2.0.0",
+ "eventemitter3": "^4.0.1",
+ "lodash": "^4.17.21",
+ "react-is": "^18.3.1",
+ "react-smooth": "^4.0.4",
+ "recharts-scale": "^0.4.4",
+ "tiny-invariant": "^1.3.1",
+ "victory-vendor": "^36.6.8"
+ },
+ "engines": {
+ "node": ">=14"
+ },
+ "peerDependencies": {
+ "react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
+ "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
+ "node_modules/recharts-scale": {
+ "version": "0.4.5",
+ "resolved": "https://registry.npmjs.org/recharts-scale/-/recharts-scale-0.4.5.tgz",
+ "integrity": "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==",
+ "license": "MIT",
+ "dependencies": {
+ "decimal.js-light": "^2.4.1"
+ }
+ },
+ "node_modules/recharts/node_modules/react-is": {
+ "version": "18.3.1",
+ "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
+ "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==",
+ "license": "MIT"
+ },
"node_modules/reflect.getprototypeof": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
@@ -7622,6 +7894,12 @@
"@codemirror/view": "^6.0.0"
}
},
+ "node_modules/tiny-invariant": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
+ "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
+ "license": "MIT"
+ },
"node_modules/tinyglobby": {
"version": "0.2.15",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
@@ -7871,6 +8149,28 @@
"spdx-expression-parse": "^3.0.0"
}
},
+ "node_modules/victory-vendor": {
+ "version": "36.9.2",
+ "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz",
+ "integrity": "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==",
+ "license": "MIT AND ISC",
+ "dependencies": {
+ "@types/d3-array": "^3.0.3",
+ "@types/d3-ease": "^3.0.0",
+ "@types/d3-interpolate": "^3.0.1",
+ "@types/d3-scale": "^4.0.2",
+ "@types/d3-shape": "^3.1.0",
+ "@types/d3-time": "^3.0.0",
+ "@types/d3-timer": "^3.0.0",
+ "d3-array": "^3.1.6",
+ "d3-ease": "^3.0.1",
+ "d3-interpolate": "^3.0.1",
+ "d3-scale": "^4.0.2",
+ "d3-shape": "^3.1.0",
+ "d3-time": "^3.0.0",
+ "d3-timer": "^3.0.1"
+ }
+ },
"node_modules/vite": {
"version": "7.2.7",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.2.7.tgz",
diff --git a/package.json b/package.json
index 64c28ea6..87f536c1 100644
--- a/package.json
+++ b/package.json
@@ -20,7 +20,9 @@
"@emotion/styled": "^11.14.1",
"@lezer/highlight": "^1.2.3",
"@uiw/react-codemirror": "^4.25.7",
+ "@dagrejs/dagre": "^1.1.4",
"@xyflow/react": "^12.9.3",
+ "recharts": "^2.15.3",
"brace-expansion": "^2.0.2",
"cross-spawn": "^7.0.6",
"framer-motion": "^12.23.24",
diff --git a/src/domain/usecases/GetPoliciesUseCase.js b/src/domain/usecases/GetPoliciesUseCase.js
new file mode 100644
index 00000000..a6a61e89
--- /dev/null
+++ b/src/domain/usecases/GetPoliciesUseCase.js
@@ -0,0 +1,43 @@
+export class GetPoliciesUseCase {
+ constructor(kubernetesRepository) {
+ this.kubernetesRepository = kubernetesRepository;
+ }
+
+ async execute(context = null) {
+ try {
+ const policyTypes = [
+ { apiVersion: 'apiextensions.crossplane.io/v1alpha1', kind: 'CompositionValidationPolicy' },
+ ];
+
+ const allPolicies = [];
+
+ for (const policyType of policyTypes) {
+ try {
+ const result = await this.kubernetesRepository.getResources(
+ policyType.apiVersion, policyType.kind, null, context
+ );
+ const items = result.items || result;
+ const itemsArray = Array.isArray(items) ? items : [];
+
+ allPolicies.push(...itemsArray.map(policy => ({
+ name: policy.metadata?.name || 'unknown',
+ namespace: policy.metadata?.namespace || null,
+ uid: policy.metadata?.uid || '',
+ creationTimestamp: policy.metadata?.creationTimestamp || '',
+ labels: policy.metadata?.labels || {},
+ spec: policy.spec || {},
+ status: policy.status || {},
+ conditions: policy.status?.conditions || [],
+ apiVersion: policyType.apiVersion,
+ kind: policyType.kind,
+ })));
+ } catch {
+ }
+ }
+
+ return allPolicies;
+ } catch (error) {
+ throw new Error(`Failed to get policies: ${error.message}`);
+ }
+ }
+}
diff --git a/src/presentation/App.jsx b/src/presentation/App.jsx
index 2ee0d768..8d7e2581 100644
--- a/src/presentation/App.jsx
+++ b/src/presentation/App.jsx
@@ -15,6 +15,8 @@ import { Resources } from './pages/Resources.jsx';
import { ResourceKind } from './pages/ResourceKind.jsx';
import { CompositeResourceKind } from './pages/CompositeResourceKind.jsx';
import { Search } from './pages/Search.jsx';
+import { Policies } from './pages/Policies.jsx';
+import { Analytics } from './pages/Analytics.jsx';
import { useAppContext } from './providers/AppProvider.jsx';
import { OnWatchResourcesProvider } from './providers/OnWatchResourcesProvider.jsx';
import { Box, Text, VStack, Icon, Button } from '@chakra-ui/react';
@@ -133,6 +135,8 @@ function App() {
} />
} />
} />
+ } />
+ } />
} />
} />
} />
diff --git a/src/presentation/components/common/ResourceDetails.jsx b/src/presentation/components/common/ResourceDetails.jsx
index e73ae176..eddb0c52 100644
--- a/src/presentation/components/common/ResourceDetails.jsx
+++ b/src/presentation/components/common/ResourceDetails.jsx
@@ -16,6 +16,8 @@ import { ResourceYAML } from './ResourceYAML.jsx';
import { ResourceStatus } from './ResourceStatus.jsx';
import { ResourceRelations } from './ResourceRelations.jsx';
import { ResourceEvents } from './ResourceEvents.jsx';
+import { ResourceDrift } from './ResourceDrift.jsx';
+import { ResourceHistory } from './ResourceHistory.jsx';
import { getBorderColor } from '../../utils/theme.js';
export const ResourceDetails = ({ resource, onClose, onNavigate, onBack }) => {
@@ -213,6 +215,14 @@ export const ResourceDetails = ({ resource, onClose, onNavigate, onBack }) => {
colorMode={colorMode}
/>
)}
+
+ {activeTab === 'drift' && (
+
+ )}
+
+ {activeTab === 'history' && (
+
+ )}
)}
diff --git a/src/presentation/components/common/ResourceDrift.jsx b/src/presentation/components/common/ResourceDrift.jsx
new file mode 100644
index 00000000..d4efdc1f
--- /dev/null
+++ b/src/presentation/components/common/ResourceDrift.jsx
@@ -0,0 +1,170 @@
+import { Box, Text, HStack, VStack, Badge } from '@chakra-ui/react';
+import { useState, useEffect } from 'react';
+import { useAppContext } from '../../providers/AppProvider.jsx';
+import { getBackgroundColor, getBorderColor, getTextColor } from '../../utils/theme.js';
+
+export const ResourceDrift = ({ resource, fullResource }) => {
+ const { selectedContext, colorMode } = useAppContext();
+ const [drift, setDrift] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ const fetchDrift = async () => {
+ if (!resource || !selectedContext) return;
+
+ setLoading(true);
+ setError(null);
+
+ try {
+ const contextName = typeof selectedContext === 'string' ? selectedContext : selectedContext.name || selectedContext;
+ const apiVersion = fullResource?.apiVersion || resource.apiVersion;
+ const kind = fullResource?.kind || resource.kind;
+ const name = resource.name || fullResource?.metadata?.name;
+ const namespace = resource.namespace || fullResource?.metadata?.namespace;
+
+ const params = new URLSearchParams({ apiVersion, kind, name, context: contextName });
+ if (namespace && namespace !== 'undefined' && namespace !== 'null') {
+ params.set('namespace', namespace);
+ }
+
+ const response = await fetch(`/api/resource/drift?${params}`);
+ if (!response.ok) throw new Error('Failed to fetch drift data');
+ const data = await response.json();
+ setDrift(data);
+ } catch (err) {
+ setError(err.message);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ fetchDrift();
+ }, [resource, fullResource, selectedContext]);
+
+ if (loading) {
+ return (
+
+ Analyzing drift...
+
+ );
+ }
+
+ if (error) {
+ return (
+
+ Failed to analyze drift: {error}
+
+ );
+ }
+
+ if (!drift) return null;
+
+ return (
+
+
+
+
+
+ {drift.hasDrift ? 'Drift Detected' : 'No Drift'}
+
+
+
+
+ {drift.driftSummary}
+
+
+ {drift.syncedCondition && (
+
+
+
+ Synced
+
+
+ {drift.syncedCondition.status}
+
+
+ {drift.syncedCondition.reason && (
+
+ {drift.syncedCondition.reason}: {drift.syncedCondition.message}
+
+ )}
+
+ )}
+
+ {drift.readyCondition && (
+
+
+
+ Ready
+
+
+ {drift.readyCondition.status}
+
+
+ {drift.readyCondition.reason && (
+
+ {drift.readyCondition.reason}: {drift.readyCondition.message}
+
+ )}
+
+ )}
+
+ {drift.fieldDiffs && drift.fieldDiffs.length > 0 && (
+
+
+ Field Differences ({drift.fieldDiffs.length})
+
+
+ {drift.fieldDiffs.map((diff, idx) => (
+
+
+ {diff.path}
+
+
+
+ Desired
+
+ {JSON.stringify(diff.desired)}
+
+
+
+ Actual
+
+ {JSON.stringify(diff.actual)}
+
+
+
+
+ ))}
+
+
+ )}
+
+
+ );
+};
diff --git a/src/presentation/components/common/ResourceHistory.jsx b/src/presentation/components/common/ResourceHistory.jsx
new file mode 100644
index 00000000..e9aae39e
--- /dev/null
+++ b/src/presentation/components/common/ResourceHistory.jsx
@@ -0,0 +1,135 @@
+import { Box, Text, VStack, HStack, Badge } from '@chakra-ui/react';
+import { useState, useEffect } from 'react';
+import { useAppContext } from '../../providers/AppProvider.jsx';
+import { getBackgroundColor, getBorderColor, getTextColor } from '../../utils/theme.js';
+
+export const ResourceHistory = ({ resource, fullResource }) => {
+ const { selectedContext, colorMode } = useAppContext();
+ const [history, setHistory] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ const fetchHistory = async () => {
+ if (!resource || !selectedContext) return;
+
+ setLoading(true);
+ setError(null);
+
+ try {
+ const contextName = typeof selectedContext === 'string' ? selectedContext : selectedContext.name || selectedContext;
+ const apiVersion = fullResource?.apiVersion || resource.apiVersion;
+ const kind = fullResource?.kind || resource.kind;
+ const name = resource.name || fullResource?.metadata?.name;
+ const namespace = resource.namespace || fullResource?.metadata?.namespace;
+
+ const params = new URLSearchParams({ apiVersion, kind, name, context: contextName, limit: '50' });
+ if (namespace && namespace !== 'undefined' && namespace !== 'null') {
+ params.set('namespace', namespace);
+ }
+
+ const response = await fetch(`/api/resource/history?${params}`);
+ if (!response.ok) {
+ if (response.status === 501) {
+ setError('History tracking requires database to be enabled');
+ return;
+ }
+ throw new Error('Failed to fetch history');
+ }
+ const data = await response.json();
+ setHistory(data.events || []);
+ } catch (err) {
+ setError(err.message);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ fetchHistory();
+ }, [resource, fullResource, selectedContext]);
+
+ if (loading) {
+ return (
+
+ Loading history...
+
+ );
+ }
+
+ if (error) {
+ return (
+
+ {error}
+
+ );
+ }
+
+ if (history.length === 0) {
+ return (
+
+ No history recorded yet
+
+ );
+ }
+
+ const getEventColor = (eventType) => {
+ switch (eventType) {
+ case 'created': return 'green';
+ case 'updated': return 'blue';
+ case 'deleted': return 'red';
+ default: return 'gray';
+ }
+ };
+
+ return (
+
+
+ {history.map((event, idx) => (
+
+ {idx < history.length - 1 && (
+
+ )}
+
+
+
+
+
+ {event.eventType}
+
+
+ {new Date(event.createdAt).toLocaleString()}
+
+
+
+ {event.kind}/{event.name}
+
+
+
+
+ ))}
+
+
+ );
+};
diff --git a/src/presentation/components/common/ResourceTabs.jsx b/src/presentation/components/common/ResourceTabs.jsx
index de3e3b34..563770e7 100644
--- a/src/presentation/components/common/ResourceTabs.jsx
+++ b/src/presentation/components/common/ResourceTabs.jsx
@@ -1,5 +1,5 @@
import { HStack, Button } from '@chakra-ui/react';
-import { FiActivity } from 'react-icons/fi';
+import { FiActivity, FiGitBranch, FiClock } from 'react-icons/fi';
export const ResourceTabs = ({
activeTab,
@@ -100,6 +100,40 @@ export const ResourceTabs = ({
Events
)}
+
+
);
};
diff --git a/src/presentation/components/layout/Sidebar.jsx b/src/presentation/components/layout/Sidebar.jsx
index 0db97654..c68f56dc 100644
--- a/src/presentation/components/layout/Sidebar.jsx
+++ b/src/presentation/components/layout/Sidebar.jsx
@@ -6,7 +6,7 @@ import {
Button,
Image,
} from '@chakra-ui/react';
-import { FiChevronLeft, FiChevronRight, FiChevronDown, FiChevronUp, FiLayout, FiSettings, FiPackage, FiFileText, FiLayers, FiBox, FiBook, FiServer, FiUsers, FiSliders, FiGrid, FiDatabase, FiCode, FiGithub, FiShield } from 'react-icons/fi';
+import { FiChevronLeft, FiChevronRight, FiChevronDown, FiChevronUp, FiLayout, FiSettings, FiPackage, FiFileText, FiLayers, FiBox, FiBook, FiServer, FiUsers, FiSliders, FiGrid, FiDatabase, FiCode, FiGithub, FiShield, FiBarChart2 } from 'react-icons/fi';
import { useState, useEffect, useRef } from 'react';
import { useNavigate, useLocation } from 'react-router-dom';
import { useAppContext } from '../../providers/AppProvider.jsx';
@@ -152,6 +152,7 @@ export const Sidebar = ({ onToggle, onResize }) => {
{ id: 'compositions', label: 'Compositions', icon: FiLayers, path: '/compositions', tooltip: 'Templates that define how to compose resources' },
{ id: 'mrds', label: 'MRDs', icon: FiLayers, path: '/mrds', tooltip: 'Managed Resource Definitions - available managed resource types from providers' },
{ id: 'mraps', label: 'MRAPs', icon: FiShield, path: '/mraps', tooltip: 'Managed Resource Activation Policies - control managed resource activation' },
+ { id: 'policies', label: 'Policies', icon: FiShield, path: '/policies', tooltip: 'Crossplane validation policies' },
// Crossplane Instances (created resources)
{
id: 'composite-resources',
@@ -168,6 +169,7 @@ export const Sidebar = ({ onToggle, onResize }) => {
},
{ id: 'claims', label: 'Claims', icon: FiFileText, path: '/claims', tooltip: 'User-facing abstractions that create Composite Resources' },
{ id: 'managed-resources', label: 'Managed Resources', icon: FiServer, path: '/managed-resources', tooltip: 'Kubernetes resources created and managed by Crossplane (Deployments, Services, etc.)' },
+ { id: 'analytics', label: 'Analytics', icon: FiBarChart2, path: '/analytics', tooltip: 'Resource metrics, health trends, and usage analytics' },
{
id: 'settings',
label: 'Settings',
diff --git a/src/presentation/hooks/useResourceTree.js b/src/presentation/hooks/useResourceTree.js
new file mode 100644
index 00000000..5b931b32
--- /dev/null
+++ b/src/presentation/hooks/useResourceTree.js
@@ -0,0 +1,46 @@
+import { useState, useEffect, useCallback } from 'react';
+import { useAppContext } from '../providers/AppProvider.jsx';
+
+export const useResourceTree = (resource) => {
+ const { kubernetesRepository, selectedContext } = useAppContext();
+ const [tree, setTree] = useState(null);
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(null);
+
+ const fetchTree = useCallback(async () => {
+ if (!resource || !selectedContext) return;
+
+ setLoading(true);
+ setError(null);
+
+ try {
+ const contextName = typeof selectedContext === 'string' ? selectedContext : selectedContext.name || selectedContext;
+ const params = new URLSearchParams({
+ apiVersion: resource.apiVersion,
+ kind: resource.kind,
+ name: resource.name,
+ context: contextName,
+ });
+ if (resource.namespace) {
+ params.set('namespace', resource.namespace);
+ }
+
+ const response = await fetch(`/api/resource/tree?${params}`);
+ if (!response.ok) {
+ throw new Error('Failed to fetch resource tree');
+ }
+ const data = await response.json();
+ setTree(data);
+ } catch (err) {
+ setError(err.message);
+ } finally {
+ setLoading(false);
+ }
+ }, [resource, selectedContext, kubernetesRepository]);
+
+ useEffect(() => {
+ fetchTree();
+ }, [fetchTree]);
+
+ return { tree, loading, error };
+};
diff --git a/src/presentation/pages/Analytics.jsx b/src/presentation/pages/Analytics.jsx
new file mode 100644
index 00000000..dc8b8c38
--- /dev/null
+++ b/src/presentation/pages/Analytics.jsx
@@ -0,0 +1,193 @@
+import {
+ Box,
+ Text,
+ HStack,
+ VStack,
+} from '@chakra-ui/react';
+import { useState, useEffect } from 'react';
+import { useAppContext } from '../providers/AppProvider.jsx';
+import { getBackgroundColor, getBorderColor, getTextColor } from '../utils/theme.js';
+
+export const Analytics = () => {
+ const { selectedContext, colorMode } = useAppContext();
+ const [summary, setSummary] = useState(null);
+ const [trend, setTrend] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ const fetchData = async () => {
+ if (!selectedContext) {
+ setLoading(false);
+ return;
+ }
+
+ setLoading(true);
+ setError(null);
+
+ try {
+ const contextName = typeof selectedContext === 'string' ? selectedContext : selectedContext.name || selectedContext;
+
+ const [summaryRes, trendRes] = await Promise.all([
+ fetch(`/api/metrics/summary?context=${contextName}`),
+ fetch(`/api/metrics/health-trend?context=${contextName}`),
+ ]);
+
+ if (summaryRes.ok) {
+ const summaryData = await summaryRes.json();
+ setSummary(summaryData);
+ }
+
+ if (trendRes.ok) {
+ const trendData = await trendRes.json();
+ setTrend(trendData.metrics || []);
+ }
+ } catch (err) {
+ setError(err.message);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ fetchData();
+ }, [selectedContext]);
+
+ if (!selectedContext) {
+ return (
+
+ Select a Kubernetes context to view analytics
+
+ );
+ }
+
+ if (loading) {
+ return (
+
+ Loading analytics...
+
+ );
+ }
+
+ if (error) {
+ return (
+
+ {error}
+
+ );
+ }
+
+ const healthPercent = summary && summary.totalResources > 0
+ ? Math.round((summary.healthyCount / summary.totalResources) * 100)
+ : 0;
+
+ return (
+
+
+ Analytics
+
+
+
+
+
+
+ = 80 ? 'green.500' : healthPercent >= 50 ? 'yellow.500' : 'red.500'}
+ colorMode={colorMode}
+ />
+
+
+
+
+ {trend.length > 0 ? (
+
+
+ Health Trend (Last 24h)
+
+
+ {trend.map((m, idx) => {
+ const total = m.totalResources || 1;
+ const pct = Math.round((m.healthyCount / total) * 100);
+ return (
+
+
+ {new Date(m.timestamp).toLocaleString()}
+
+
+ = 80 ? 'green.400' : pct >= 50 ? 'yellow.400' : 'red.400'}
+ w={`${pct}%`}
+ transition="width 0.3s"
+ />
+
+
+ {pct}%
+
+
+ );
+ })}
+
+
+ ) : (
+
+
+ No trend data yet. Metrics are collected periodically when the database is enabled.
+
+
+ )}
+
+ );
+};
+
+const StatCard = ({ label, value, color, colorMode }) => (
+
+ {label}
+ {value}
+
+);
diff --git a/src/presentation/pages/Policies.jsx b/src/presentation/pages/Policies.jsx
new file mode 100644
index 00000000..50d64bd5
--- /dev/null
+++ b/src/presentation/pages/Policies.jsx
@@ -0,0 +1,245 @@
+import {
+ Box,
+ Text,
+ HStack,
+} from '@chakra-ui/react';
+import { useEffect, useState, useRef, useCallback } from 'react';
+import { useLocation } from 'react-router-dom';
+import { useAppContext } from '../providers/AppProvider.jsx';
+import { DataTable } from '../components/common/DataTable.jsx';
+import { ResourceDetails } from '../components/common/ResourceDetails.jsx';
+import { getStatusColor, getStatusText } from '../utils/resourceStatus.js';
+
+export const Policies = () => {
+ const location = useLocation();
+ const { kubernetesRepository, selectedContext } = useAppContext();
+ const [loading, setLoading] = useState(true);
+ const [selectedResource, setSelectedResource] = useState(null);
+ const [navigationHistory, setNavigationHistory] = useState([]);
+ const [useAutoHeight, setUseAutoHeight] = useState(false);
+ const tableContainerRef = useRef(null);
+
+ useEffect(() => {
+ setSelectedResource(null);
+ setNavigationHistory([]);
+ }, [location.pathname]);
+
+ const fetchPolicies = useCallback(async (page, limit, searchTerm = '', searchableFields = []) => {
+ if (!selectedContext) {
+ return { items: [], totalCount: 0 };
+ }
+
+ const contextName = typeof selectedContext === 'string' ? selectedContext : selectedContext.name || selectedContext;
+
+ const policyTypes = [
+ { apiVersion: 'apiextensions.crossplane.io/v1alpha1', kind: 'CompositionValidationPolicy' },
+ ];
+
+ const transformPolicies = (items, apiVersion, kind) => {
+ return items.map(policy => ({
+ name: policy.metadata?.name || 'unknown',
+ namespace: policy.metadata?.namespace || null,
+ uid: policy.metadata?.uid || '',
+ creationTimestamp: policy.metadata?.creationTimestamp || '',
+ labels: policy.metadata?.labels || {},
+ conditions: policy.status?.conditions || [],
+ validationActions: policy.spec?.validationActions || [],
+ matchLabels: policy.spec?.matchLabels || {},
+ spec: policy.spec || {},
+ status: policy.status || {},
+ apiVersion,
+ kind,
+ }));
+ };
+
+ const applySearchFilter = (items) => {
+ const trimmedSearch = searchTerm.trim().toLowerCase();
+ if (!trimmedSearch || searchableFields.length === 0) {
+ return items;
+ }
+ return items.filter(item => {
+ return searchableFields.some(field => {
+ const value = field.split('.').reduce((obj, key) => obj?.[key], item);
+ return String(value || '').toLowerCase().includes(trimmedSearch);
+ });
+ });
+ };
+
+ try {
+ const allItems = [];
+
+ for (const policyType of policyTypes) {
+ try {
+ let continueToken = null;
+ do {
+ const result = await kubernetesRepository.getResources(
+ policyType.apiVersion, policyType.kind, null, contextName, 100, continueToken
+ );
+ const batch = result.items || [];
+ allItems.push(...transformPolicies(batch, policyType.apiVersion, policyType.kind));
+ continueToken = result.continueToken || null;
+ } while (continueToken);
+ } catch {
+ }
+ }
+
+ const filteredItems = applySearchFilter(allItems);
+ const startIndex = (page - 1) * limit;
+
+ return {
+ items: filteredItems.slice(startIndex, startIndex + limit),
+ totalCount: filteredItems.length,
+ continueToken: null,
+ };
+ } catch (err) {
+ throw new Error(`Failed to fetch policies: ${err.message}`);
+ }
+ }, [kubernetesRepository, selectedContext]);
+
+ useEffect(() => {
+ if (!selectedContext) {
+ setLoading(false);
+ return;
+ }
+ setLoading(false);
+ }, [selectedContext]);
+
+ useEffect(() => {
+ if (!selectedResource || !tableContainerRef.current) {
+ setUseAutoHeight(false);
+ return;
+ }
+
+ const checkTableHeight = () => {
+ const container = tableContainerRef.current;
+ if (!container) return;
+ const viewportHeight = window.innerHeight;
+ const halfViewport = (viewportHeight - 100) * 0.5;
+ const tableHeight = container.scrollHeight;
+ setUseAutoHeight(tableHeight > halfViewport);
+ };
+
+ checkTableHeight();
+ const resizeObserver = new ResizeObserver(checkTableHeight);
+ resizeObserver.observe(tableContainerRef.current);
+ return () => resizeObserver.disconnect();
+ }, [selectedResource, loading]);
+
+ const handleRowClick = (item) => {
+ const clickedResource = {
+ apiVersion: item.apiVersion,
+ kind: item.kind,
+ name: item.name,
+ namespace: item.namespace || null,
+ };
+
+ if (selectedResource &&
+ selectedResource.name === clickedResource.name &&
+ selectedResource.kind === clickedResource.kind &&
+ selectedResource.apiVersion === clickedResource.apiVersion &&
+ selectedResource.namespace === clickedResource.namespace) {
+ setSelectedResource(null);
+ setNavigationHistory([]);
+ return;
+ }
+
+ setNavigationHistory([]);
+ setSelectedResource(clickedResource);
+ };
+
+ const handleNavigate = (resource) => {
+ setNavigationHistory(prev => [...prev, selectedResource]);
+ setSelectedResource(resource);
+ };
+
+ const handleBack = () => {
+ if (navigationHistory.length > 0) {
+ const previous = navigationHistory.at(-1);
+ setNavigationHistory(prev => prev.slice(0, -1));
+ setSelectedResource(previous);
+ } else {
+ setSelectedResource(null);
+ }
+ };
+
+ const handleClose = () => {
+ setSelectedResource(null);
+ setNavigationHistory([]);
+ };
+
+ const columns = [
+ {
+ header: 'Name',
+ accessor: 'name',
+ minWidth: '200px',
+ },
+ {
+ header: 'Kind',
+ accessor: 'kind',
+ minWidth: '180px',
+ },
+ {
+ header: 'Status',
+ accessor: 'conditions',
+ minWidth: '120px',
+ render: (row) => {
+ const color = getStatusColor(row.conditions, row.kind);
+ const text = getStatusText(row.conditions, row.kind);
+ return (
+
+
+ {text}
+
+ );
+ },
+ },
+ {
+ header: 'Created',
+ accessor: 'creationTimestamp',
+ minWidth: '150px',
+ render: (row) => row.creationTimestamp ? new Date(row.creationTimestamp).toLocaleString() : '-',
+ },
+ ];
+
+ return (
+
+
+ Policies
+
+
+
+
+
+
+
+ {selectedResource && (
+
+ 0 ? handleBack : undefined}
+ />
+
+ )}
+
+
+ );
+};