From 9f3bd6475f37605cdacca4cf5d01f69af88223d6 Mon Sep 17 00:00:00 2001 From: Takin Date: Tue, 7 Apr 2026 22:51:53 +0200 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20add=20advanced=20features=20?= =?UTF-8?q?=E2=80=94=20policies,=20dependency=20graph,=20drift=20detection?= =?UTF-8?q?,=20history,=20analytics?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Takin --- .../kubernetes/kubernetes_controller.go | 202 ++++++++++++++- crossview-go-server/api/routes/kubernetes.go | 6 + crossview-go-server/commands/serve.go | 17 ++ crossview-go-server/models/resource_event.go | 86 ++++++ crossview-go-server/models/resource_metric.go | 83 ++++++ .../services/kubernetes_drift.go | 151 +++++++++++ .../services/kubernetes_service.go | 2 + .../services/kubernetes_tree.go | 211 +++++++++++++++ .../services/metrics_collector.go | 149 +++++++++++ package.json | 2 + src/domain/usecases/GetPoliciesUseCase.js | 43 +++ src/presentation/App.jsx | 4 + .../components/common/ResourceDetails.jsx | 10 + .../components/common/ResourceDrift.jsx | 170 ++++++++++++ .../components/common/ResourceHistory.jsx | 135 ++++++++++ .../components/common/ResourceTabs.jsx | 36 ++- .../components/layout/Sidebar.jsx | 4 +- src/presentation/hooks/useResourceTree.js | 46 ++++ src/presentation/pages/Analytics.jsx | 193 ++++++++++++++ src/presentation/pages/Policies.jsx | 245 ++++++++++++++++++ 20 files changed, 1788 insertions(+), 7 deletions(-) create mode 100644 crossview-go-server/models/resource_event.go create mode 100644 crossview-go-server/models/resource_metric.go create mode 100644 crossview-go-server/services/kubernetes_drift.go create mode 100644 crossview-go-server/services/kubernetes_tree.go create mode 100644 crossview-go-server/services/metrics_collector.go create mode 100644 src/domain/usecases/GetPoliciesUseCase.js create mode 100644 src/presentation/components/common/ResourceDrift.jsx create mode 100644 src/presentation/components/common/ResourceHistory.jsx create mode 100644 src/presentation/hooks/useResourceTree.js create mode 100644 src/presentation/pages/Analytics.jsx create mode 100644 src/presentation/pages/Policies.jsx 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/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..01ebade3 --- /dev/null +++ b/crossview-go-server/services/kubernetes_tree.go @@ -0,0 +1,211 @@ +package services + +import ( + "fmt" + "strings" + + "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.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} + /> + + )} + + + ); +}; From 1bc891a7b2b7c76573ce0be118dfb96e03ab07c0 Mon Sep 17 00:00:00 2001 From: Takin Date: Tue, 21 Apr 2026 20:09:05 +0200 Subject: [PATCH 2/4] fix: remove unused import in kubernetes_tree.go Signed-off-by: Takin --- crossview-go-server/services/kubernetes_tree.go | 1 - 1 file changed, 1 deletion(-) diff --git a/crossview-go-server/services/kubernetes_tree.go b/crossview-go-server/services/kubernetes_tree.go index 01ebade3..ba963124 100644 --- a/crossview-go-server/services/kubernetes_tree.go +++ b/crossview-go-server/services/kubernetes_tree.go @@ -2,7 +2,6 @@ package services import ( "fmt" - "strings" "k8s.io/client-go/dynamic" ) From f4a3684deeac3fdd1ffa354f9e9f78b30a088f35 Mon Sep 17 00:00:00 2001 From: Takin Date: Tue, 21 Apr 2026 20:12:14 +0200 Subject: [PATCH 3/4] fix: update controller tests for new constructor signature Signed-off-by: Takin --- .../kubernetes/kubernetes_controller_test.go | 49 ++++++++++--------- .../kubernetes/kubernetes_test_helpers.go | 20 +++++++- 2 files changed, 44 insertions(+), 25 deletions(-) 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 +} + From 83d88e8631535528ca30a4f317b035dfff72dbd1 Mon Sep 17 00:00:00 2001 From: Takin Date: Tue, 21 Apr 2026 20:15:28 +0200 Subject: [PATCH 4/4] fix: sync package-lock.json with new dependencies Signed-off-by: Takin --- package-lock.json | 304 +++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 302 insertions(+), 2 deletions(-) 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",