diff --git a/backend/cmd/api/main.go b/backend/cmd/api/main.go index 641c4b0..35632f9 100644 --- a/backend/cmd/api/main.go +++ b/backend/cmd/api/main.go @@ -285,6 +285,8 @@ func setupRouter( protected.HandleFunc("/clusters/{cluster_id}/instances/{instance_id}/entries", instanceHandler.ListInstanceEntries).Methods(http.MethodGet) protected.HandleFunc("/clusters/{cluster_id}/instances/{instance_id}/diagnostics", instanceHandler.GetInstanceDiagnostics).Methods(http.MethodGet) protected.HandleFunc("/clusters/{cluster_id}/instances/{instance_id}/logs/stream", instanceHandler.StreamInstanceLogs).Methods(http.MethodGet) + protected.HandleFunc("/clusters/{cluster_id}/instances/{instance_id}/scale", instanceHandler.ScaleInstance).Methods(http.MethodPost) + protected.HandleFunc("/clusters/{cluster_id}/instances/{instance_id}/values-diff", instanceHandler.GetInstanceValuesDiff).Methods(http.MethodGet) // ===== Monitoring 路由 ===== protected.HandleFunc("/monitoring/clusters", monitoringHandler.ListClusterMonitoring).Methods(http.MethodGet) diff --git a/backend/internal/adapter/input/http/dto/instance_dto.go b/backend/internal/adapter/input/http/dto/instance_dto.go index 8c70de5..d839456 100644 --- a/backend/internal/adapter/input/http/dto/instance_dto.go +++ b/backend/internal/adapter/input/http/dto/instance_dto.go @@ -206,6 +206,25 @@ type InstanceEventDiagnostics struct { LastTimestamp string `json:"lastTimestamp,omitempty"` } +// ScaleInstanceRequest 扩缩容实例请求 +type ScaleInstanceRequest struct { + Replicas int `json:"replicas" binding:"required"` + Workload string `json:"workload"` +} + +// ScaleInstanceResponse 扩缩容实例响应 +type ScaleInstanceResponse struct { + Instance *InstanceResponse `json:"instance"` + Replicas int `json:"replicas"` + Message string `json:"message"` +} + +// InstanceValuesDiffResponse 实例 values 差异响应 +type InstanceValuesDiffResponse struct { + Current map[string]interface{} `json:"current"` + Defaults map[string]interface{} `json:"defaults"` +} + type InstancePodLogResponse struct { Pod string `json:"pod"` Container string `json:"container"` diff --git a/backend/internal/adapter/input/http/rest/instance_handler.go b/backend/internal/adapter/input/http/rest/instance_handler.go index d3f4457..78b76d3 100644 --- a/backend/internal/adapter/input/http/rest/instance_handler.go +++ b/backend/internal/adapter/input/http/rest/instance_handler.go @@ -371,6 +371,49 @@ func (h *InstanceHandler) StreamInstanceLogs(w http.ResponseWriter, r *http.Requ } } +// ScaleInstance 扩缩容实例 +func (h *InstanceHandler) ScaleInstance(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + clusterID := vars["cluster_id"] + instanceID := vars["instance_id"] + + var req dto.ScaleInstanceRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + respondError(w, http.StatusBadRequest, "Invalid request body", err.Error()) + return + } + if req.Replicas < 0 { + respondError(w, http.StatusBadRequest, "Invalid replicas", "replicas must be >= 0") + return + } + + result, err := h.instanceService.ScaleInstance(r.Context(), clusterID, instanceID, req.Replicas, req.Workload) + if err != nil { + respondServiceError(w, err, "Failed to scale instance") + return + } + + respondJSON(w, http.StatusOK, dto.ScaleInstanceResponse{ + Instance: convertInstanceResponse(result, true), + Replicas: req.Replicas, + Message: fmt.Sprintf("Scaled to %d replicas", req.Replicas), + }) +} + +// GetInstanceValuesDiff 获取实例 values 差异 +func (h *InstanceHandler) GetInstanceValuesDiff(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + clusterID := vars["cluster_id"] + instanceID := vars["instance_id"] + + diff, err := h.instanceService.GetInstanceValuesDiff(r.Context(), clusterID, instanceID) + if err != nil { + respondServiceError(w, err, "Failed to get values diff") + return + } + respondJSON(w, http.StatusOK, diff) +} + func convertInstanceEntry(entry *entity.InstanceEntry) *dto.InstanceEntryResponse { portResponses := make([]dto.InstanceEntryPortResponse, 0, len(entry.Ports)) for _, port := range entry.Ports { diff --git a/backend/internal/adapter/output/helm/mock/helm_client_mock.go b/backend/internal/adapter/output/helm/mock/helm_client_mock.go index ce00280..e17f577 100644 --- a/backend/internal/adapter/output/helm/mock/helm_client_mock.go +++ b/backend/internal/adapter/output/helm/mock/helm_client_mock.go @@ -194,3 +194,13 @@ func (c *HelmClientMock) GetValues(ctx context.Context, cluster *entity.Cluster, return instance.Values, nil } +func (c *HelmClientMock) GetChartDefaultValues(chartPath string) (map[string]interface{}, error) { + return map[string]interface{}{ + "replicaCount": 1, + "image": map[string]interface{}{ + "repository": "nginx", + "tag": "latest", + }, + }, nil +} + diff --git a/backend/internal/adapter/output/helm/real/helm_client.go b/backend/internal/adapter/output/helm/real/helm_client.go index f743cfe..e8c51c3 100644 --- a/backend/internal/adapter/output/helm/real/helm_client.go +++ b/backend/internal/adapter/output/helm/real/helm_client.go @@ -159,6 +159,7 @@ func (h *HelmClient) Upgrade(ctx context.Context, cluster *entity.Cluster, insta upgrade := action.NewUpgrade(actionConfig) upgrade.Namespace = instance.Namespace + upgrade.ReuseValues = true upgrade.Wait = true upgrade.Timeout = helmOperationTimeout() @@ -321,6 +322,7 @@ func (h *HelmClient) GetValues(ctx context.Context, cluster *entity.Cluster, rel defer cleanup() getValues := action.NewGetValues(actionConfig) + getValues.AllValues = true values, err := getValues.Run(releaseName) if err != nil { return nil, fmt.Errorf("failed to get values: %w", err) @@ -329,6 +331,21 @@ func (h *HelmClient) GetValues(ctx context.Context, cluster *entity.Cluster, rel return values, nil } +// GetChartDefaultValues 从 chart 包中读取默认 values +func (h *HelmClient) GetChartDefaultValues(chartPath string) (map[string]interface{}, error) { + chart, err := loader.Load(chartPath) + if err != nil { + return nil, fmt.Errorf("failed to load chart: %w", err) + } + vals := make(map[string]interface{}) + if chart.Values != nil { + for k, v := range chart.Values { + vals[k] = v + } + } + return vals, nil +} + // convertReleaseToInstance 转换 Helm Release 为 Instance func (h *HelmClient) convertReleaseToInstance(rel *release.Release) *entity.Instance { return &entity.Instance{ diff --git a/backend/internal/domain/repository/helm_client.go b/backend/internal/domain/repository/helm_client.go index 7262522..42015f7 100644 --- a/backend/internal/domain/repository/helm_client.go +++ b/backend/internal/domain/repository/helm_client.go @@ -30,4 +30,7 @@ type HelmClient interface { // GetValues 获取 Release 的 values GetValues(ctx context.Context, cluster *entity.Cluster, releaseName, namespace string) (map[string]interface{}, error) + + // GetChartDefaultValues 从 chart 包中读取默认 values + GetChartDefaultValues(chartPath string) (map[string]interface{}, error) } diff --git a/backend/internal/domain/service/instance_service.go b/backend/internal/domain/service/instance_service.go index d714b4f..80914b2 100644 --- a/backend/internal/domain/service/instance_service.go +++ b/backend/internal/domain/service/instance_service.go @@ -9,6 +9,7 @@ import ( "time" "github.com/google/uuid" + "github.com/ocdp/cluster-service/internal/adapter/input/http/dto" "github.com/ocdp/cluster-service/internal/domain/entity" "github.com/ocdp/cluster-service/internal/domain/repository" "github.com/ocdp/cluster-service/internal/pkg/authz" @@ -417,6 +418,80 @@ func (s *InstanceService) StreamInstanceLogs(ctx context.Context, clusterID, ins return streamer.StreamPodLogs(ctx, cluster, instance.Namespace, podName, containerName, tailLines) } +// ScaleInstance 扩缩容实例(修改 replicaCount 后执行 Helm upgrade) +func (s *InstanceService) ScaleInstance(ctx context.Context, clusterID, instanceID string, replicas int, workload string) (*entity.Instance, error) { + principal, err := authz.RequirePrincipal(ctx) + if err != nil { + return nil, entity.ErrUnauthorized + } + instance, err := s.instanceRepo.GetByID(ctx, instanceID) + if err != nil { + return nil, entity.ErrInstanceNotFound + } + if !s.canWriteInstance(principal, instance) { + return nil, entity.ErrForbidden + } + cluster, err := s.clusterRepo.GetByID(ctx, clusterID) + if err != nil { + return nil, entity.ErrClusterNotFound + } + + // Get existing Helm values and patch replicaCount + vals, err := s.helmClient.GetValues(ctx, cluster, instance.Name, instance.Namespace) + if err != nil { + return nil, fmt.Errorf("failed to get current values: %w", err) + } + if vals == nil { + vals = make(map[string]interface{}) + } + vals["replicaCount"] = replicas + + instance.SetValues(vals) + instance.BeginOperation(entity.OperationUpgrade, fmt.Sprintf("Scaling to %d replicas", replicas)) + if err := s.instanceRepo.Update(ctx, instance); err != nil { + return nil, err + } + + go s.executeAndSyncUpgrade(context.Background(), instance.ID, cluster, nil, instance) + return instance, nil +} + +// GetInstanceValuesDiff 获取实例当前 values 与 chart 默认 values 的差异 +func (s *InstanceService) GetInstanceValuesDiff(ctx context.Context, clusterID, instanceID string) (*dto.InstanceValuesDiffResponse, error) { + principal, err := authz.RequirePrincipal(ctx) + if err != nil { + return nil, entity.ErrUnauthorized + } + instance, err := s.instanceRepo.GetByID(ctx, instanceID) + if err != nil { + return nil, entity.ErrInstanceNotFound + } + if !s.canReadInstance(principal, instance) { + return nil, entity.ErrInstanceNotFound + } + cluster, err := s.clusterRepo.GetByID(ctx, clusterID) + if err != nil { + return nil, entity.ErrClusterNotFound + } + + current, err := s.helmClient.GetValues(ctx, cluster, instance.Name, instance.Namespace) + if err != nil { + return nil, err + } + + // Get default values from the chart archive + chartPath := s.chartArchivePath(instance) + defaults, err := s.helmClient.GetChartDefaultValues(chartPath) + if err != nil { + return nil, fmt.Errorf("failed to read chart defaults: %w", err) + } + + return &dto.InstanceValuesDiffResponse{ + Current: current, + Defaults: defaults, + }, nil +} + func (s *InstanceService) canReadInstance(principal *authz.Principal, instance *entity.Instance) bool { if principal.IsAdmin() { return true diff --git a/backend/internal/domain/service/instance_service_test.go b/backend/internal/domain/service/instance_service_test.go index d7b1e43..23e3102 100644 --- a/backend/internal/domain/service/instance_service_test.go +++ b/backend/internal/domain/service/instance_service_test.go @@ -167,5 +167,9 @@ func (*stubHelmClient) GetValues(ctx context.Context, cluster *entity.Cluster, r return nil, nil } +func (*stubHelmClient) GetChartDefaultValues(chartPath string) (map[string]interface{}, error) { + return nil, nil +} + var _ repository.ClusterRepository = (*stubClusterRepo)(nil) var _ repository.HelmClient = (*stubHelmClient)(nil) diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index c9da3a1..1724e29 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -237,6 +237,26 @@ export const getInstance = getClustersClusterIdInstancesInstanceId; export const updateInstance = putClustersClusterIdInstancesInstanceId; export const deleteInstance = deleteClustersClusterIdInstancesInstanceId; export const listInstanceEntries = getClustersClusterIdInstancesInstanceIdEntries; +export const scaleInstance = ( + clusterId: string, + instanceId: string, + body: { replicas: number; workload?: string }, +) => { + return customAxiosInstance<{ instance: any; replicas: number; message: string }>({ + url: `/clusters/${encodeURIComponent(clusterId)}/instances/${encodeURIComponent(instanceId)}/scale`, + method: "POST", + data: body, + }); +}; +export const getInstanceValuesDiff = ( + clusterId: string, + instanceId: string, +) => { + return customAxiosInstance<{ current: Record; defaults: Record }>({ + url: `/clusters/${encodeURIComponent(clusterId)}/instances/${encodeURIComponent(instanceId)}/values-diff`, + method: "GET", + }); +}; export const getInstanceDiagnostics = ( params: { clusterId: string; instanceId: string }, options?: { tailLines?: number }, diff --git a/frontend/src/features/artifact/instances/components/InstanceCard.tsx b/frontend/src/features/artifact/instances/components/InstanceCard.tsx index 95c4b1f..18302b5 100644 --- a/frontend/src/features/artifact/instances/components/InstanceCard.tsx +++ b/frontend/src/features/artifact/instances/components/InstanceCard.tsx @@ -19,9 +19,12 @@ import { AlertTriangle, History, HelpCircle, + Minus, + Plus, + Loader2, } from "lucide-react"; import type { InstanceResponse, InstanceStatus } from "@/api"; -import { INSTANCE_LAST_OPERATION, INSTANCE_STATUS } from "@/api"; +import { INSTANCE_LAST_OPERATION, INSTANCE_STATUS, scaleInstance } from "@/api"; interface InstanceCardProps { instance: InstanceResponse; @@ -29,6 +32,7 @@ interface InstanceCardProps { onTerminate: (instance: InstanceResponse) => void; onViewEntries: (instance: InstanceResponse) => void; onViewDiagnostics: (instance: InstanceResponse) => void; + onScale?: (instance: InstanceResponse) => void; } type StatusVisual = { @@ -136,7 +140,9 @@ export const InstanceCard: React.FC = ({ onTerminate, onViewEntries, onViewDiagnostics, + onScale, }) => { + const [scaling, setScaling] = React.useState(false); const normalizedStatus = (instance.status ?? INSTANCE_STATUS.unknown) as InstanceStatus; const statusInfo = STATUS_INFO_MAP[normalizedStatus] ?? STATUS_INFO_MAP[INSTANCE_STATUS.unknown]; @@ -163,120 +169,161 @@ export const InstanceCard: React.FC = ({ const lastError = typeof instance.lastError === "string" ? instance.lastError.trim() : ""; + // Extract replica count from values + const parsedValues = React.useMemo(() => { + if (!instance.values) return null; + try { + return typeof instance.values === "string" + ? JSON.parse(instance.values) + : (instance.values as Record); + } catch { + return null; + } + }, [instance.values]); + const currentReplicas = parsedValues?.replicaCount ?? parsedValues?.replicas ?? 1; + + const handleScale = async (delta: number) => { + const newReplicas = Math.max(0, currentReplicas + delta); + if (newReplicas === currentReplicas) return; + if (!instance.clusterId || !instance.id) return; + + setScaling(true); + try { + const result = await scaleInstance(instance.clusterId, instance.id, { replicas: newReplicas }); + onScale?.(result.instance ?? instance); + } catch (err) { + console.error("[InstanceCard] Scale failed:", err); + } finally { + setScaling(false); + } + }; + return ( -
- {/* Decorative gradient overlay */} -
- - {/* Header with enhanced design */} -
-
-
- {/* Enhanced icon with glow effect */} -
- -
+
+ + {/* Header - compact */} +
+
+
+ {/* Icon */} +
+
- +
-

+

{instanceName}

-
- -

- {repository} -

- - +
+ + {repository} + · + {version}
- {/* Enhanced Status Badge with glow */} + {/* Status Badge - prominent but smaller */}
- - + + {statusLabel}
-
- {statusReason} + {/* Status reason + last operation - compact inline */} +
+ {statusReason} {lastOperationLabel && ( - - Operation: {lastOperationLabel} - + <> + | + + {lastOperationLabel} + + )}
- {/* Enhanced Content Grid */} -
-
- {/* Namespace */} -
-
- -

Namespace

+ {/* Content - compact 3-column layout */} +
+ {/* Row 1: Namespace | Revision | Launched */} +
+
+
+ +

Namespace

-

- {namespace} -

+

{namespace}

- - {/* Revision */} -
-
- -

Revision

+
+
+ +

Revision

-

- {revision} -

+

{revision}

- - {/* Repository - Full Width */} -
-
- -

Repository

+
+
+ +

Launched

-

- {repository} -

-
- - {/* Launched Date - Full Width */} -
-
- -

Launched

-
-

- {createdAtText} -

+

{createdAtText}

{lastError && ( -
-
- -
-
-

Last error

-

{lastError}

+
+ +
+

Last error

+

{lastError}

)}
+ {/* Scale Controls */} + {instance.status === "deployed" && ( +
+ Replicas: +
+ + + {currentReplicas} + + +
+
+ )} + {/* Enhanced Actions Bar */}
diff --git a/frontend/src/features/artifact/instances/components/ModifyModal.tsx b/frontend/src/features/artifact/instances/components/ModifyModal.tsx index 10beb20..1003dc1 100644 --- a/frontend/src/features/artifact/instances/components/ModifyModal.tsx +++ b/frontend/src/features/artifact/instances/components/ModifyModal.tsx @@ -7,7 +7,7 @@ import React, { useState, useEffect } from "react"; import { Settings } from "lucide-react"; import { parse as parseYaml, stringify as stringifyYaml } from "yaml"; import type { InstanceResponse, UpdateInstanceRequest } from "@/api"; -import { getValuesSchema } from "@/api"; +import { getValuesSchema, getInstanceValuesDiff } from "@/api"; import { Modal, Button, @@ -44,6 +44,15 @@ export const ModifyModal: React.FC = ({ const [inputMethod, setInputMethod] = useState<'form' | 'yaml'>('yaml'); const [formValues, setFormValues] = useState>({}); + // Values Diff support + const [showDiff, setShowDiff] = useState(false); + const [loadingDiff, setLoadingDiff] = useState(false); + const [diffData, setDiffData] = useState<{ + current: Record; + defaults: Record; + } | null>(null); + const [diffError, setDiffError] = useState(null); + // Initialize with current values useEffect(() => { setTag(instance.version || ""); @@ -65,6 +74,9 @@ export const ModifyModal: React.FC = ({ // Load values schema loadValuesSchema(); + + // Load values diff + loadValuesDiff(); }, [instance]); const loadValuesSchema = async () => { @@ -100,6 +112,61 @@ export const ModifyModal: React.FC = ({ } }; + const loadValuesDiff = async () => { + if (!instance.clusterId || !instance.id) return; + + setLoadingDiff(true); + setDiffError(null); + setDiffData(null); + try { + const data = await getInstanceValuesDiff(instance.clusterId, instance.id); + if (data && data.current && data.defaults) { + setDiffData({ current: data.current, defaults: data.defaults }); + } + } catch (err) { + console.error("[ModifyModal] Failed to load values diff:", err); + setDiffError("Failed to load values diff"); + } finally { + setLoadingDiff(false); + } + }; + + const applyDefaults = () => { + if (!diffData?.defaults) return; + const defaultYaml = stringifyYaml(diffData.defaults); + setValuesYaml(defaultYaml); + setFormValues(diffData.defaults); + }; + + /** + * Render a values object as YAML lines, bolding keys that differ from defaults. + */ + const renderDiffValues = ( + values: Record, + compare: Record, + ): React.ReactNode => { + const yaml = stringifyYaml(values); + const lines = yaml.split("\n"); + return lines.map((line, i) => { + // Extract the key name from a YAML line + const keyMatch = line.match(/^(\s*)([a-zA-Z_][\w-]*)\s*:/); + if (keyMatch) { + const key = keyMatch[2]; + const keyChanged = + compare[key] !== undefined && + JSON.stringify(values[key]) !== JSON.stringify(compare[key]); + if (keyChanged) { + return ( + + {keyMatch[1]}{key}:{line.slice(keyMatch[0].length)} + + ); + } + } + return {line}; + }); + }; + const handleFormValuesChange = (values: Record) => { setFormValues(values); setValuesYaml(stringifyYaml(values)); @@ -266,6 +333,80 @@ export const ModifyModal: React.FC = ({ )}
+ {/* Values Diff Section */} + {instance.clusterId && instance.id && ( +
+ + + {showDiff && ( +
+ {loadingDiff && ( + + )} + {diffError && ( + + )} + {diffData && ( + <> +
+ {/* Current Values */} +
+
+ + Current + + deployed +
+
+                          {renderDiffValues(diffData.current, diffData.defaults)}
+                        
+
+ {/* Default Values */} +
+
+ + Defaults + + chart +
+
+                          {renderDiffValues(diffData.defaults, diffData.current)}
+                        
+
+
+ {/* Legend */} +

+ Bold amber keys differ between current and default values. +

+ {/* Use Defaults Button */} + + + )} +
+ )} +
+ )} +

Update applies the selected chart version and values override. Resource readiness is tracked from the instance list after submit.

diff --git a/frontend/src/features/artifact/instances/pages/InstancesManagementPage.tsx b/frontend/src/features/artifact/instances/pages/InstancesManagementPage.tsx index 270cc30..80106fe 100644 --- a/frontend/src/features/artifact/instances/pages/InstancesManagementPage.tsx +++ b/frontend/src/features/artifact/instances/pages/InstancesManagementPage.tsx @@ -421,7 +421,7 @@ const InstancesManagementPage: React.FC = () => {

-
+
{instances.map((instance) => ( { ); }) ) : ( -
+
{filteredInstances.map(({ instance }) => ( = ({ registryId, registryUrl, tag }) => { +export const TagCard: React.FC = ({ registryId, registryUrl, tag, isLatest = false }) => { const { success } = useToast(); const [launchModalOpen, setLaunchModalOpen] = useState(false); const category = inferArtifactCategory(tag); @@ -73,67 +74,62 @@ export const TagCard: React.FC = ({ registryId, registryUrl, tag } return ( <> -
-
- {/* Icon */} -
-
- {getTypeIcon(category)} -
+
+ {/* LATEST badge */} + {isLatest && ( + + LATEST + + )} + + {/* Top row: tag name + type badge */} +
+
+ {getTypeIcon(category)}
+

+ {tag.tag || 'N/A'} +

+ + {category} + +
- {/* Content */} -
- {/* Tag name */} -
- -

- {tag.tag || 'N/A'} -

- - {category} - -
+ {/* Repository path */} +

+ {tag.repositoryName || ' '} +

- {/* Repository path */} -

- {tag.repositoryName} -

- - {/* Size */} -
- - {formatSize(tag.size || 0)} -
+ {/* Bottom row: size + actions */} +
+
+ + {formatSize(tag.size || 0)}
- - {/* Actions */} -
+
+ {category === "chart" && ( )} - -
diff --git a/frontend/src/features/artifact/registries/pages/ArtifactBrowserPage.tsx b/frontend/src/features/artifact/registries/pages/ArtifactBrowserPage.tsx index 97619bb..77fd68b 100644 --- a/frontend/src/features/artifact/registries/pages/ArtifactBrowserPage.tsx +++ b/frontend/src/features/artifact/registries/pages/ArtifactBrowserPage.tsx @@ -11,6 +11,8 @@ import { Search, ChevronRight, ChevronDown, + ChevronLeft, + LayoutGrid, } from "lucide-react"; import { useToast } from "@/shared"; import { @@ -67,7 +69,9 @@ const ArtifactBrowserPage: React.FC = () => { const [artifactError, setArtifactError] = useState(null); const [filter, setFilter] = useState("chart"); + const [sidebarCollapsed, setSidebarCollapsed] = useState(false); const [searchTerm, setSearchTerm] = useState(""); + const [tagSearchTerm, setTagSearchTerm] = useState(""); const loadArtifacts = useCallback( async ( @@ -249,6 +253,12 @@ const ArtifactBrowserPage: React.FC = () => { ); }, [registryNodes, searchTerm]); + const filteredArtifacts = useMemo(() => { + const term = tagSearchTerm.trim().toLowerCase(); + if (!term) return artifacts; + return artifacts.filter((a) => (a.tag || "").toLowerCase().includes(term)); + }, [artifacts, tagSearchTerm]); + const selectedRegistryName = selectedRepository ? registryNodes.find((node) => node.registry.id === selectedRepository.registryId)?.registry .name @@ -283,120 +293,170 @@ const ArtifactBrowserPage: React.FC = () => {
-
{!selectedRepository ? ( + /* Placeholder when no repo is selected */
) : ( <> + {/* Right panel header */}

Chart repository

-

+

{selectedRepository.name}

@@ -422,7 +482,27 @@ const ArtifactBrowserPage: React.FC = () => {

-
+ {/* Tag search bar */} +
+
+ + setTagSearchTerm(e.target.value)} + className="w-full pl-8 pr-3 py-1.5 rounded-lg bg-white border border-slate-200 text-sm text-slate-900 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500" + /> +
+ {tagSearchTerm && ( +

+ Showing {filteredArtifacts.length} of {artifacts.length} tags +

+ )} +
+ + {/* Tag grid */} +
{artifactError && (

{artifactError}

)} @@ -438,14 +518,21 @@ const ArtifactBrowserPage: React.FC = () => { : "This repository doesn't contain any tagged artifacts yet." } /> + ) : filteredArtifacts.length === 0 ? ( + ) : ( -
- {artifacts.map((artifact, index) => ( +
+ {filteredArtifacts.map((artifact, index) => ( ))}
diff --git a/frontend/src/index.css b/frontend/src/index.css index 3a2eff2..c4d4e4c 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -28,3 +28,19 @@ to { opacity: 1; transform: translateY(0); } } .animate-fadeIn { animation: fadeIn 0.25s ease-out; } + +/* Hover animation utilities */ +.hover-lift { + transition: transform 0.2s ease, box-shadow 0.2s ease; +} +.hover-lift:hover { + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); +} + +.hover-glow { + transition: box-shadow 0.2s ease; +} +.hover-glow:hover { + box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.3); +} diff --git a/frontend/src/shared/components/layout/SidebarLayout/SidebarNav.tsx b/frontend/src/shared/components/layout/SidebarLayout/SidebarNav.tsx index a18420d..e42fa9a 100644 --- a/frontend/src/shared/components/layout/SidebarLayout/SidebarNav.tsx +++ b/frontend/src/shared/components/layout/SidebarLayout/SidebarNav.tsx @@ -53,10 +53,10 @@ export default function SidebarNav({ items = [] as NavItem[], isOpen = true, onC