dev #1

Merged
ivanwu merged 15 commits from dev into main 2026-05-22 09:41:12 +00:00
175 changed files with 16794 additions and 3392 deletions
Showing only changes of commit 28ecb2e636 - Show all commits

View File

@ -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}/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}/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}/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 路由 ===== // ===== Monitoring 路由 =====
protected.HandleFunc("/monitoring/clusters", monitoringHandler.ListClusterMonitoring).Methods(http.MethodGet) protected.HandleFunc("/monitoring/clusters", monitoringHandler.ListClusterMonitoring).Methods(http.MethodGet)

View File

@ -206,6 +206,25 @@ type InstanceEventDiagnostics struct {
LastTimestamp string `json:"lastTimestamp,omitempty"` 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 { type InstancePodLogResponse struct {
Pod string `json:"pod"` Pod string `json:"pod"`
Container string `json:"container"` Container string `json:"container"`

View File

@ -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 { func convertInstanceEntry(entry *entity.InstanceEntry) *dto.InstanceEntryResponse {
portResponses := make([]dto.InstanceEntryPortResponse, 0, len(entry.Ports)) portResponses := make([]dto.InstanceEntryPortResponse, 0, len(entry.Ports))
for _, port := range entry.Ports { for _, port := range entry.Ports {

View File

@ -194,3 +194,13 @@ func (c *HelmClientMock) GetValues(ctx context.Context, cluster *entity.Cluster,
return instance.Values, nil 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
}

View File

@ -159,6 +159,7 @@ func (h *HelmClient) Upgrade(ctx context.Context, cluster *entity.Cluster, insta
upgrade := action.NewUpgrade(actionConfig) upgrade := action.NewUpgrade(actionConfig)
upgrade.Namespace = instance.Namespace upgrade.Namespace = instance.Namespace
upgrade.ReuseValues = true
upgrade.Wait = true upgrade.Wait = true
upgrade.Timeout = helmOperationTimeout() upgrade.Timeout = helmOperationTimeout()
@ -321,6 +322,7 @@ func (h *HelmClient) GetValues(ctx context.Context, cluster *entity.Cluster, rel
defer cleanup() defer cleanup()
getValues := action.NewGetValues(actionConfig) getValues := action.NewGetValues(actionConfig)
getValues.AllValues = true
values, err := getValues.Run(releaseName) values, err := getValues.Run(releaseName)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to get values: %w", err) 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 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 // convertReleaseToInstance 转换 Helm Release 为 Instance
func (h *HelmClient) convertReleaseToInstance(rel *release.Release) *entity.Instance { func (h *HelmClient) convertReleaseToInstance(rel *release.Release) *entity.Instance {
return &entity.Instance{ return &entity.Instance{

View File

@ -30,4 +30,7 @@ type HelmClient interface {
// GetValues 获取 Release 的 values // GetValues 获取 Release 的 values
GetValues(ctx context.Context, cluster *entity.Cluster, releaseName, namespace string) (map[string]interface{}, error) GetValues(ctx context.Context, cluster *entity.Cluster, releaseName, namespace string) (map[string]interface{}, error)
// GetChartDefaultValues 从 chart 包中读取默认 values
GetChartDefaultValues(chartPath string) (map[string]interface{}, error)
} }

View File

@ -9,6 +9,7 @@ import (
"time" "time"
"github.com/google/uuid" "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/entity"
"github.com/ocdp/cluster-service/internal/domain/repository" "github.com/ocdp/cluster-service/internal/domain/repository"
"github.com/ocdp/cluster-service/internal/pkg/authz" "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) 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 { func (s *InstanceService) canReadInstance(principal *authz.Principal, instance *entity.Instance) bool {
if principal.IsAdmin() { if principal.IsAdmin() {
return true return true

View File

@ -167,5 +167,9 @@ func (*stubHelmClient) GetValues(ctx context.Context, cluster *entity.Cluster, r
return nil, nil return nil, nil
} }
func (*stubHelmClient) GetChartDefaultValues(chartPath string) (map[string]interface{}, error) {
return nil, nil
}
var _ repository.ClusterRepository = (*stubClusterRepo)(nil) var _ repository.ClusterRepository = (*stubClusterRepo)(nil)
var _ repository.HelmClient = (*stubHelmClient)(nil) var _ repository.HelmClient = (*stubHelmClient)(nil)

View File

@ -237,6 +237,26 @@ export const getInstance = getClustersClusterIdInstancesInstanceId;
export const updateInstance = putClustersClusterIdInstancesInstanceId; export const updateInstance = putClustersClusterIdInstancesInstanceId;
export const deleteInstance = deleteClustersClusterIdInstancesInstanceId; export const deleteInstance = deleteClustersClusterIdInstancesInstanceId;
export const listInstanceEntries = getClustersClusterIdInstancesInstanceIdEntries; 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<string, any>; defaults: Record<string, any> }>({
url: `/clusters/${encodeURIComponent(clusterId)}/instances/${encodeURIComponent(instanceId)}/values-diff`,
method: "GET",
});
};
export const getInstanceDiagnostics = ( export const getInstanceDiagnostics = (
params: { clusterId: string; instanceId: string }, params: { clusterId: string; instanceId: string },
options?: { tailLines?: number }, options?: { tailLines?: number },

View File

@ -19,9 +19,12 @@ import {
AlertTriangle, AlertTriangle,
History, History,
HelpCircle, HelpCircle,
Minus,
Plus,
Loader2,
} from "lucide-react"; } from "lucide-react";
import type { InstanceResponse, InstanceStatus } from "@/api"; 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 { interface InstanceCardProps {
instance: InstanceResponse; instance: InstanceResponse;
@ -29,6 +32,7 @@ interface InstanceCardProps {
onTerminate: (instance: InstanceResponse) => void; onTerminate: (instance: InstanceResponse) => void;
onViewEntries: (instance: InstanceResponse) => void; onViewEntries: (instance: InstanceResponse) => void;
onViewDiagnostics: (instance: InstanceResponse) => void; onViewDiagnostics: (instance: InstanceResponse) => void;
onScale?: (instance: InstanceResponse) => void;
} }
type StatusVisual = { type StatusVisual = {
@ -136,7 +140,9 @@ export const InstanceCard: React.FC<InstanceCardProps> = ({
onTerminate, onTerminate,
onViewEntries, onViewEntries,
onViewDiagnostics, onViewDiagnostics,
onScale,
}) => { }) => {
const [scaling, setScaling] = React.useState(false);
const normalizedStatus = (instance.status ?? INSTANCE_STATUS.unknown) as InstanceStatus; const normalizedStatus = (instance.status ?? INSTANCE_STATUS.unknown) as InstanceStatus;
const statusInfo = const statusInfo =
STATUS_INFO_MAP[normalizedStatus] ?? STATUS_INFO_MAP[INSTANCE_STATUS.unknown]; STATUS_INFO_MAP[normalizedStatus] ?? STATUS_INFO_MAP[INSTANCE_STATUS.unknown];
@ -163,120 +169,161 @@ export const InstanceCard: React.FC<InstanceCardProps> = ({
const lastError = const lastError =
typeof instance.lastError === "string" ? instance.lastError.trim() : ""; 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<string, any>);
} 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 ( return (
<div className="group relative bg-gradient-to-br from-white via-white to-slate-50 border border-slate-200 rounded-xl hover:border-blue-500/50 hover:shadow-xl hover:shadow-blue-500/10 transition-all duration-300 overflow-hidden"> <div className="hover-lift group relative bg-white border border-slate-200 rounded-xl hover:border-blue-500/50 duration-200 overflow-hidden">
{/* Decorative gradient overlay */}
<div className="absolute top-0 right-0 w-64 h-64 bg-gradient-to-br from-blue-500/5 to-purple-500/5 rounded-full blur-3xl -z-0 opacity-0 group-hover:opacity-100 transition-opacity duration-500"></div> {/* Header - compact */}
<div className="relative px-4 py-3 border-b border-slate-200 bg-gradient-to-r from-slate-50 to-white">
{/* Header with enhanced design */} <div className="flex items-start justify-between gap-3">
<div className="relative px-6 py-5 border-b border-slate-200 bg-gradient-to-r from-slate-50 to-white"> <div className="flex items-start gap-3 flex-1 min-w-0">
<div className="flex items-start justify-between"> {/* Icon */}
<div className="flex items-start gap-4 flex-1"> <div className="flex-shrink-0 p-2 bg-gradient-to-br from-blue-500/20 to-cyan-500/20 rounded-lg border border-blue-500/30">
{/* Enhanced icon with glow effect */} <Box className="w-5 h-5 text-blue-400" />
<div className="relative p-3 bg-gradient-to-br from-blue-500/20 to-cyan-500/20 rounded-xl border border-blue-500/30 shadow-lg shadow-blue-500/20 group-hover:shadow-blue-500/40 transition-shadow duration-300">
<Box className="w-7 h-7 text-blue-400" />
<div className="absolute inset-0 bg-blue-400/10 rounded-xl blur-sm"></div>
</div> </div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<h3 className="text-xl font-bold text-slate-950 truncate"> <h3 className="text-base font-bold text-slate-950 truncate">
{instanceName} {instanceName}
</h3> </h3>
<div className="flex items-center gap-2 mt-2"> <div className="flex items-center gap-1.5 mt-1">
<Package className="w-4 h-4 text-slate-500" /> <Package className="w-3.5 h-3.5 text-slate-400 flex-shrink-0" />
<p className="text-sm text-slate-500 font-mono"> <span className="text-sm text-slate-500 font-mono truncate">{repository}</span>
{repository} <span className="text-slate-300 flex-shrink-0">&#183;</span>
</p> <span className="px-1.5 py-0.5 text-[11px] font-semibold text-cyan-400 bg-cyan-500/10 border border-cyan-500/30 rounded flex-shrink-0">
<span className="text-slate-600"></span>
<span className="px-2 py-0.5 text-xs font-semibold text-cyan-400 bg-cyan-500/10 border border-cyan-500/30 rounded">
{version} {version}
</span> </span>
</div> </div>
</div> </div>
</div> </div>
{/* Enhanced Status Badge with glow */} {/* Status Badge - prominent but smaller */}
<div <div
className={`flex items-center gap-2 px-4 py-2 rounded-full border shadow-lg ${statusInfo.bg} ${statusInfo.glow} backdrop-blur-sm`} className={`flex items-center gap-1.5 px-3 py-1 rounded-full border shadow-sm ${statusInfo.bg} flex-shrink-0`}
> >
<StatusIcon className={`w-4 h-4 ${statusInfo.color}`} /> <StatusIcon className={`w-3.5 h-3.5 ${statusInfo.color}`} />
<span className={`text-sm font-semibold ${statusInfo.color} uppercase tracking-wide`}> <span className={`text-xs font-semibold ${statusInfo.color} uppercase tracking-wide`}>
{statusLabel} {statusLabel}
</span> </span>
</div> </div>
</div> </div>
<div className="mt-4 flex flex-col gap-1 text-sm text-slate-700"> {/* Status reason + last operation - compact inline */}
<span className="font-medium text-slate-700">{statusReason}</span> <div className="mt-2 flex items-center gap-2 text-xs text-slate-600">
<span className="truncate">{statusReason}</span>
{lastOperationLabel && ( {lastOperationLabel && (
<span className="text-xs uppercase tracking-wide text-slate-500"> <>
Operation: {lastOperationLabel} <span className="text-slate-300 flex-shrink-0">|</span>
</span> <span className="uppercase tracking-wide text-slate-400 whitespace-nowrap flex-shrink-0">
{lastOperationLabel}
</span>
</>
)} )}
</div> </div>
</div> </div>
{/* Enhanced Content Grid */} {/* Content - compact 3-column layout */}
<div className="relative px-6 py-5 space-y-4 bg-gradient-to-b from-white to-slate-50"> <div className="px-4 py-3 space-y-2">
<div className="grid grid-cols-2 gap-4"> {/* Row 1: Namespace | Revision | Launched */}
{/* Namespace */} <div className="grid grid-cols-3 gap-3">
<div className="p-3 bg-white border border-slate-200 rounded-lg hover:border-purple-500/30 transition-colors"> <div>
<div className="flex items-center gap-2 mb-2"> <div className="flex items-center gap-1 mb-0.5">
<Layers className="w-4 h-4 text-purple-400" /> <Layers className="w-3 h-3 text-purple-400 flex-shrink-0" />
<p className="text-xs text-slate-500 uppercase font-semibold tracking-wider">Namespace</p> <p className="text-[11px] text-slate-500 uppercase font-semibold tracking-wider">Namespace</p>
</div> </div>
<p className="text-sm font-bold text-slate-900"> <p className="text-sm font-medium text-slate-900">{namespace}</p>
{namespace}
</p>
</div> </div>
<div>
{/* Revision */} <div className="flex items-center gap-1 mb-0.5">
<div className="p-3 bg-white border border-slate-200 rounded-lg hover:border-green-500/30 transition-colors"> <GitBranch className="w-3 h-3 text-green-400 flex-shrink-0" />
<div className="flex items-center gap-2 mb-2"> <p className="text-[11px] text-slate-500 uppercase font-semibold tracking-wider">Revision</p>
<GitBranch className="w-4 h-4 text-green-400" />
<p className="text-xs text-slate-500 uppercase font-semibold tracking-wider">Revision</p>
</div> </div>
<p className="text-sm font-bold text-slate-900"> <p className="text-sm font-medium text-slate-900">{revision}</p>
{revision}
</p>
</div> </div>
<div>
{/* Repository - Full Width */} <div className="flex items-center gap-1 mb-0.5">
<div className="col-span-2 p-3 bg-white border border-slate-200 rounded-lg hover:border-blue-500/30 transition-colors"> <Calendar className="w-3 h-3 text-amber-400 flex-shrink-0" />
<div className="flex items-center gap-2 mb-2"> <p className="text-[11px] text-slate-500 uppercase font-semibold tracking-wider">Launched</p>
<Package className="w-4 h-4 text-blue-400" />
<p className="text-xs text-slate-500 uppercase font-semibold tracking-wider">Repository</p>
</div> </div>
<p className="text-sm font-mono text-slate-900 truncate" title={repository}> <p className="text-sm font-medium text-slate-900">{createdAtText}</p>
{repository}
</p>
</div>
{/* Launched Date - Full Width */}
<div className="col-span-2 p-3 bg-white border border-slate-200 rounded-lg hover:border-amber-500/30 transition-colors">
<div className="flex items-center gap-2 mb-2">
<Calendar className="w-4 h-4 text-amber-400" />
<p className="text-xs text-slate-500 uppercase font-semibold tracking-wider">Launched</p>
</div>
<p className="text-sm font-bold text-slate-900">
{createdAtText}
</p>
</div> </div>
</div> </div>
{lastError && ( {lastError && (
<div className="flex items-start gap-3 p-4 border border-rose-500/30 bg-rose-500/10 rounded-lg"> <div className="flex items-start gap-2 p-2.5 border border-rose-500/30 bg-rose-500/10 rounded-lg">
<div className="p-2 bg-rose-500/20 rounded-lg border border-rose-500/40"> <AlertTriangle className="w-4 h-4 text-rose-500 flex-shrink-0 mt-0.5" />
<AlertTriangle className="w-5 h-5 text-rose-700" /> <div className="min-w-0">
</div> <p className="text-xs font-semibold text-rose-700">Last error</p>
<div> <p className="text-xs text-rose-600 truncate">{lastError}</p>
<p className="text-sm font-semibold text-rose-200">Last error</p>
<p className="text-sm text-rose-100/90">{lastError}</p>
</div> </div>
</div> </div>
)} )}
</div> </div>
{/* Scale Controls */}
{instance.status === "deployed" && (
<div className="flex items-center justify-between gap-2 px-6 py-3 bg-slate-50/50 border-t border-gray-100 dark:border-gray-700">
<span className="text-xs text-gray-500 dark:text-gray-400 font-medium">Replicas:</span>
<div className="flex items-center gap-1">
<button
onClick={() => handleScale(-1)}
disabled={scaling || currentReplicas <= 0}
className="inline-flex items-center justify-center w-8 h-8 rounded-lg border border-slate-200 bg-white text-slate-600 hover:border-blue-400 hover:text-blue-600 hover:bg-blue-50 disabled:opacity-40 disabled:cursor-not-allowed transition-all duration-150"
title="Scale down"
>
{scaling ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Minus className="w-4 h-4" />
)}
</button>
<span className="inline-flex items-center justify-center min-w-[2.5rem] px-2 py-1 text-sm font-bold text-slate-900 bg-white border border-slate-200 rounded-lg">
{currentReplicas}
</span>
<button
onClick={() => handleScale(1)}
disabled={scaling}
className="inline-flex items-center justify-center w-8 h-8 rounded-lg border border-slate-200 bg-white text-slate-600 hover:border-blue-400 hover:text-blue-600 hover:bg-blue-50 disabled:opacity-40 disabled:cursor-not-allowed transition-all duration-150"
title="Scale up"
>
{scaling ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Plus className="w-4 h-4" />
)}
</button>
</div>
</div>
)}
{/* Enhanced Actions Bar */} {/* Enhanced Actions Bar */}
<div className="relative px-6 py-4 bg-gradient-to-r from-slate-50 via-slate-50 to-white border-t border-slate-200 backdrop-blur-sm"> <div className="relative px-6 py-4 bg-gradient-to-r from-slate-50 via-slate-50 to-white border-t border-slate-200 backdrop-blur-sm">
<div className="grid grid-cols-2 gap-2 md:grid-cols-2 xl:grid-cols-4"> <div className="grid grid-cols-2 gap-2 md:grid-cols-2 xl:grid-cols-4">

View File

@ -7,7 +7,7 @@ import React, { useState, useEffect } from "react";
import { Settings } from "lucide-react"; import { Settings } from "lucide-react";
import { parse as parseYaml, stringify as stringifyYaml } from "yaml"; import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
import type { InstanceResponse, UpdateInstanceRequest } from "@/api"; import type { InstanceResponse, UpdateInstanceRequest } from "@/api";
import { getValuesSchema } from "@/api"; import { getValuesSchema, getInstanceValuesDiff } from "@/api";
import { import {
Modal, Modal,
Button, Button,
@ -44,6 +44,15 @@ export const ModifyModal: React.FC<ModifyModalProps> = ({
const [inputMethod, setInputMethod] = useState<'form' | 'yaml'>('yaml'); const [inputMethod, setInputMethod] = useState<'form' | 'yaml'>('yaml');
const [formValues, setFormValues] = useState<Record<string, any>>({}); const [formValues, setFormValues] = useState<Record<string, any>>({});
// Values Diff support
const [showDiff, setShowDiff] = useState(false);
const [loadingDiff, setLoadingDiff] = useState(false);
const [diffData, setDiffData] = useState<{
current: Record<string, any>;
defaults: Record<string, any>;
} | null>(null);
const [diffError, setDiffError] = useState<string | null>(null);
// Initialize with current values // Initialize with current values
useEffect(() => { useEffect(() => {
setTag(instance.version || ""); setTag(instance.version || "");
@ -65,6 +74,9 @@ export const ModifyModal: React.FC<ModifyModalProps> = ({
// Load values schema // Load values schema
loadValuesSchema(); loadValuesSchema();
// Load values diff
loadValuesDiff();
}, [instance]); }, [instance]);
const loadValuesSchema = async () => { const loadValuesSchema = async () => {
@ -100,6 +112,61 @@ export const ModifyModal: React.FC<ModifyModalProps> = ({
} }
}; };
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<string, any>,
compare: Record<string, any>,
): 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 (
<span key={i} className="block">
{keyMatch[1]}<strong className="text-amber-600 dark:text-amber-400">{key}</strong>:{line.slice(keyMatch[0].length)}
</span>
);
}
}
return <span key={i} className="block">{line}</span>;
});
};
const handleFormValuesChange = (values: Record<string, any>) => { const handleFormValuesChange = (values: Record<string, any>) => {
setFormValues(values); setFormValues(values);
setValuesYaml(stringifyYaml(values)); setValuesYaml(stringifyYaml(values));
@ -266,6 +333,80 @@ export const ModifyModal: React.FC<ModifyModalProps> = ({
)} )}
</div> </div>
{/* Values Diff Section */}
{instance.clusterId && instance.id && (
<div className="border-t border-slate-200 pt-3">
<button
type="button"
onClick={() => {
if (!showDiff && !diffData) {
loadValuesDiff();
}
setShowDiff(!showDiff);
}}
className="flex items-center gap-2 text-sm font-medium text-indigo-600 hover:text-indigo-700 transition-colors"
>
<span>{showDiff ? "Hide" : "Show"} Values Diff</span>
<span className={`text-xs transition-transform ${showDiff ? "rotate-180" : ""}`}></span>
</button>
{showDiff && (
<div className="mt-3 space-y-3">
{loadingDiff && (
<LoadingState message="Loading values diff..." />
)}
{diffError && (
<ErrorState title="Diff Error" message={diffError} />
)}
{diffData && (
<>
<div className="grid grid-cols-2 gap-3">
{/* Current Values */}
<div>
<div className="flex items-center gap-2 mb-1">
<span className="text-xs font-semibold uppercase tracking-wider text-slate-500">
Current
</span>
<Badge variant="default" size="sm">deployed</Badge>
</div>
<pre className="text-xs font-mono bg-slate-50 border border-slate-200 rounded-lg p-3 max-h-64 overflow-auto whitespace-pre text-slate-700">
{renderDiffValues(diffData.current, diffData.defaults)}
</pre>
</div>
{/* Default Values */}
<div>
<div className="flex items-center gap-2 mb-1">
<span className="text-xs font-semibold uppercase tracking-wider text-slate-500">
Defaults
</span>
<Badge variant="info" size="sm">chart</Badge>
</div>
<pre className="text-xs font-mono bg-slate-50 border border-slate-200 rounded-lg p-3 max-h-64 overflow-auto whitespace-pre text-slate-500">
{renderDiffValues(diffData.defaults, diffData.current)}
</pre>
</div>
</div>
{/* Legend */}
<p className="text-xs text-slate-500">
<strong className="text-amber-600 dark:text-amber-400">Bold amber keys</strong> differ between current and default values.
</p>
{/* Use Defaults Button */}
<button
type="button"
onClick={applyDefaults}
className="inline-flex items-center gap-1.5 text-xs font-medium text-indigo-600 hover:text-indigo-700 bg-indigo-50 hover:bg-indigo-100 border border-indigo-200 rounded-lg px-3 py-1.5 transition-all"
title="Replace current values with chart defaults"
>
<Settings className="w-3.5 h-3.5" />
Use Defaults
</button>
</>
)}
</div>
)}
</div>
)}
<p className="text-xs text-slate-500"> <p className="text-xs text-slate-500">
Update applies the selected chart version and values override. Resource readiness is tracked from the instance list after submit. Update applies the selected chart version and values override. Resource readiness is tracked from the instance list after submit.
</p> </p>

View File

@ -421,7 +421,7 @@ const InstancesManagementPage: React.FC = () => {
</p> </p>
</div> </div>
</div> </div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6"> <div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
{instances.map((instance) => ( {instances.map((instance) => (
<InstanceCard <InstanceCard
key={instance.id} key={instance.id}
@ -438,7 +438,7 @@ const InstancesManagementPage: React.FC = () => {
); );
}) })
) : ( ) : (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6"> <div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
{filteredInstances.map(({ instance }) => ( {filteredInstances.map(({ instance }) => (
<InstanceCard <InstanceCard
key={instance.id} key={instance.id}

View File

@ -13,9 +13,10 @@ interface TagCardProps {
registryId: string; registryId: string;
registryUrl?: string; registryUrl?: string;
tag: ArtifactListItem; tag: ArtifactListItem;
isLatest?: boolean;
} }
export const TagCard: React.FC<TagCardProps> = ({ registryId, registryUrl, tag }) => { export const TagCard: React.FC<TagCardProps> = ({ registryId, registryUrl, tag, isLatest = false }) => {
const { success } = useToast(); const { success } = useToast();
const [launchModalOpen, setLaunchModalOpen] = useState(false); const [launchModalOpen, setLaunchModalOpen] = useState(false);
const category = inferArtifactCategory(tag); const category = inferArtifactCategory(tag);
@ -73,67 +74,62 @@ export const TagCard: React.FC<TagCardProps> = ({ registryId, registryUrl, tag }
return ( return (
<> <>
<div className="bg-white border border-slate-200 rounded-lg p-4 hover:border-brand-blue/50 transition-all group"> <div className="hover-lift relative bg-white border border-slate-200 rounded-lg p-3 hover:border-brand-blue/50 transition-all group">
<div className="flex items-start gap-3"> {/* LATEST badge */}
{/* Icon */} {isLatest && (
<div className="flex-shrink-0"> <span className="absolute -top-1.5 right-3 px-2 py-0.5 rounded-full text-[10px] font-bold uppercase tracking-wider bg-emerald-500 text-white shadow-sm">
<div className={`w-10 h-10 rounded-lg border ${getTypeColor(category)} LATEST
flex items-center justify-center text-lg`}> </span>
{getTypeIcon(category)} )}
</div>
{/* Top row: tag name + type badge */}
<div className="flex items-center gap-2 mb-1.5">
<div className={`w-6 h-6 rounded-md border ${getTypeColor(category)} flex items-center justify-center flex-shrink-0`}>
<span className="scale-75">{getTypeIcon(category)}</span>
</div> </div>
<h3 className="text-sm font-semibold text-slate-900 truncate flex-1">
{tag.tag || 'N/A'}
</h3>
<span
className={`px-1.5 py-0.5 rounded text-[10px] border ${getTypeColor(category)} flex-shrink-0`}
title={tag.mediaType || tag.type || ''}
>
{category}
</span>
</div>
{/* Content */} {/* Repository path */}
<div className="flex-1 min-w-0"> <p className="text-[11px] text-slate-400 truncate mb-2" title={tag.repositoryName || ''}>
{/* Tag name */} {tag.repositoryName || ' '}
<div className="flex items-center gap-2 mb-1"> </p>
<Package className="w-4 h-4 text-blue-600 flex-shrink-0" />
<h3 className="text-sm font-semibold text-slate-900 truncate">
{tag.tag || 'N/A'}
</h3>
<span
className={`px-2 py-0.5 rounded text-xs border ${getTypeColor(category)}`}
title={tag.mediaType || tag.type || ''}
>
{category}
</span>
</div>
{/* Repository path */} {/* Bottom row: size + actions */}
<p className="text-xs text-slate-500 truncate mb-2"> <div className="flex items-center justify-between gap-2">
{tag.repositoryName} <div className="flex items-center gap-1.5 text-[11px] text-slate-400 flex-shrink-0">
</p> <HardDrive className="w-3 h-3" />
<span>{formatSize(tag.size || 0)}</span>
{/* Size */}
<div className="flex items-center gap-2 text-xs text-slate-500">
<HardDrive className="w-3.5 h-3.5" />
<span>{formatSize(tag.size || 0)}</span>
</div>
</div> </div>
<div className="flex items-center gap-1.5 flex-shrink-0">
{/* Actions */} <button
<div className="flex-shrink-0 flex flex-col gap-2"> onClick={handleCopy}
className="px-2 py-1 bg-white hover:bg-slate-50 text-slate-500
border border-slate-200 rounded text-[11px] transition-colors flex items-center gap-1"
title="Copy pull command"
>
<Copy className="w-3 h-3" />
<span>Copy</span>
</button>
{category === "chart" && ( {category === "chart" && (
<button <button
onClick={handleLaunch} onClick={handleLaunch}
className="px-3 py-1.5 bg-blue-600 hover:bg-blue-700 text-white rounded className="px-2 py-1 bg-blue-600 hover:bg-blue-700 text-white rounded
text-xs font-medium transition-colors flex items-center gap-1.5" text-[11px] font-medium transition-colors flex items-center gap-1"
title="Launch this Helm chart" title="Launch this Helm chart"
> >
<Rocket className="w-3.5 h-3.5" /> <Rocket className="w-3 h-3" />
<span>Launch</span> <span>Launch</span>
</button> </button>
)} )}
<button
onClick={handleCopy}
className="px-3 py-1.5 bg-white hover:bg-slate-50 text-slate-700
border border-slate-200 rounded text-xs transition-colors flex items-center gap-1.5"
title="Copy pull command"
>
<Copy className="w-3.5 h-3.5" />
<span>Copy</span>
</button>
</div> </div>
</div> </div>
</div> </div>

View File

@ -11,6 +11,8 @@ import {
Search, Search,
ChevronRight, ChevronRight,
ChevronDown, ChevronDown,
ChevronLeft,
LayoutGrid,
} from "lucide-react"; } from "lucide-react";
import { useToast } from "@/shared"; import { useToast } from "@/shared";
import { import {
@ -67,7 +69,9 @@ const ArtifactBrowserPage: React.FC = () => {
const [artifactError, setArtifactError] = useState<string | null>(null); const [artifactError, setArtifactError] = useState<string | null>(null);
const [filter, setFilter] = useState<ListArtifactsFilter | undefined>("chart"); const [filter, setFilter] = useState<ListArtifactsFilter | undefined>("chart");
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
const [searchTerm, setSearchTerm] = useState(""); const [searchTerm, setSearchTerm] = useState("");
const [tagSearchTerm, setTagSearchTerm] = useState("");
const loadArtifacts = useCallback( const loadArtifacts = useCallback(
async ( async (
@ -249,6 +253,12 @@ const ArtifactBrowserPage: React.FC = () => {
); );
}, [registryNodes, searchTerm]); }, [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 const selectedRegistryName = selectedRepository
? registryNodes.find((node) => node.registry.id === selectedRepository.registryId)?.registry ? registryNodes.find((node) => node.registry.id === selectedRepository.registryId)?.registry
.name .name
@ -283,120 +293,170 @@ const ArtifactBrowserPage: React.FC = () => {
</div> </div>
</div> </div>
<div className="flex-1 flex overflow-hidden bg-slate-50"> <div className="flex-1 flex overflow-hidden bg-slate-50">
<aside className="w-80 border-r border-slate-200 bg-white flex flex-col"> {/* Collapsible side panel */}
<div className="p-4 border-b border-slate-200 space-y-2"> <aside
<div className="relative"> className={`border-r border-slate-200 bg-white flex flex-col transition-all duration-200 ${
<Search className="absolute left-2 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500" /> sidebarCollapsed ? "w-12" : "w-80"
<input }`}
type="text" >
placeholder="Search registries / repositories..." {sidebarCollapsed ? (
value={searchTerm} /* Collapsed state: narrow strip with just a toggle */
onChange={(e) => setSearchTerm(e.target.value)} <div className="flex flex-col items-center pt-3 gap-4">
className="w-full pl-8 pr-3 py-2 rounded-lg bg-white border border-slate-200 text-sm text-slate-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500" <button
/> onClick={() => setSidebarCollapsed(false)}
</div> className="p-1.5 hover:bg-slate-100 rounded-md transition"
{repositoryError && ( title="Expand sidebar"
<p className="text-xs text-red-400">{repositoryError}</p> >
)} <ChevronRight className="w-4 h-4 text-slate-500" />
<div className="flex items-center justify-between text-xs text-slate-500"> </button>
<span>Registries</span> {registryNodes.slice(0, 5).map((node) => (
<Badge variant="secondary">{registryNodes.length}</Badge> <div
</div> key={node.registry.id || node.registry.name}
</div> className="w-7 h-7 rounded-md bg-blue-50 flex items-center justify-center"
title={node.registry.name || "Registry"}
<div className="flex-1 overflow-y-auto custom-scrollbar"> >
{loadingRegistries ? ( <Database className="w-3.5 h-3.5 text-blue-600" />
<div className="p-4">
<LoadingState message="Loading registries..." />
</div>
) : filteredNodes.length === 0 ? (
<div className="p-4">
<EmptyState
icon={Database}
title="No registries"
description="Add a Harbor registry to browse deployable charts."
/>
</div>
) : (
filteredNodes.map((node) => (
<div key={node.registry.id || node.registry.name}>
<button
onClick={() => toggleRegistry(node.registry.id)}
className="w-full flex items-center justify-between px-4 py-3 hover:bg-slate-50 transition"
>
<div className="flex items-center gap-2">
{node.expanded ? (
<ChevronDown className="w-4 h-4 text-slate-500" />
) : (
<ChevronRight className="w-4 h-4 text-slate-500" />
)}
<Database className="w-4 h-4 text-blue-600" />
<div className="text-left">
<p className="text-sm text-slate-900">{node.registry.name || "Unnamed"}</p>
<p className="text-[11px] text-slate-500 truncate">
{node.registry.url}
</p>
</div>
</div>
<Badge variant="secondary">{node.repositories.length}</Badge>
</button>
{node.expanded && (
<div className="bg-slate-50/60">
{node.repositories.length === 0 ? (
<p className="px-8 py-3 text-xs text-slate-500">
{loadingRepositories
? "Loading repositories..."
: "No chart repositories found."}
</p>
) : (
node.repositories.map((repo) => {
const isSelected =
selectedRepository?.registryId === repo.registryId &&
selectedRepository?.name === repo.name;
return (
<button
key={`${repo.registryId}-${repo.name}`}
onClick={() => handleRepositoryClick(repo)}
className={`w-full text-left px-8 py-2 flex items-center justify-between text-sm transition ${
isSelected
? "bg-blue-50 text-blue-700"
: "hover:bg-white/80 text-slate-700"
}`}
>
<span className="truncate">{repo.name}</span>
{repo.artifactCount !== undefined && (
<span className="text-xs text-slate-500">
{repo.artifactCount}
</span>
)}
</button>
);
})
)}
</div>
)}
</div> </div>
)) ))}
)} {registryNodes.length > 5 && (
</div> <span className="text-[10px] text-slate-400">
+{registryNodes.length - 5}
</span>
)}
</div>
) : (
/* Expanded state: full sidebar */
<>
<div className="p-4 border-b border-slate-200 space-y-2">
<div className="flex items-center gap-2">
<button
onClick={() => setSidebarCollapsed(true)}
className="p-1 hover:bg-slate-100 rounded-md transition flex-shrink-0"
title="Collapse sidebar"
>
<ChevronLeft className="w-4 h-4 text-slate-500" />
</button>
<div className="relative flex-1">
<Search className="absolute left-2 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500" />
<input
type="text"
placeholder="Search registries / repositories..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full pl-8 pr-3 py-2 rounded-lg bg-white border border-slate-200 text-sm text-slate-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
</div>
{repositoryError && (
<p className="text-xs text-red-400">{repositoryError}</p>
)}
<div className="flex items-center justify-between text-xs text-slate-500">
<span>Registries</span>
<Badge variant="secondary">{registryNodes.length}</Badge>
</div>
</div>
<div className="flex-1 overflow-y-auto custom-scrollbar">
{loadingRegistries ? (
<div className="p-4">
<LoadingState message="Loading registries..." />
</div>
) : filteredNodes.length === 0 ? (
<div className="p-4">
<EmptyState
icon={Database}
title="No registries"
description="Add a Harbor registry to browse deployable charts."
/>
</div>
) : (
filteredNodes.map((node) => (
<div key={node.registry.id || node.registry.name}>
<button
onClick={() => toggleRegistry(node.registry.id)}
className="w-full flex items-center justify-between px-4 py-3 hover:bg-slate-50 transition"
>
<div className="flex items-center gap-2 min-w-0">
{node.expanded ? (
<ChevronDown className="w-4 h-4 text-slate-500 flex-shrink-0" />
) : (
<ChevronRight className="w-4 h-4 text-slate-500 flex-shrink-0" />
)}
<Database className="w-4 h-4 text-blue-600 flex-shrink-0" />
<div className="text-left min-w-0">
<p className="text-sm text-slate-900 truncate">
{node.registry.name || "Unnamed"}
</p>
<p className="text-[11px] text-slate-500 truncate">
{node.registry.url}
</p>
</div>
</div>
<Badge variant="secondary" className="flex-shrink-0">
{node.repositories.length}
</Badge>
</button>
{node.expanded && (
<div className="bg-slate-50/60">
{node.repositories.length === 0 ? (
<p className="px-8 py-3 text-xs text-slate-500">
{loadingRepositories
? "Loading repositories..."
: "No chart repositories found."}
</p>
) : (
node.repositories.map((repo) => {
const isSelected =
selectedRepository?.registryId === repo.registryId &&
selectedRepository?.name === repo.name;
return (
<button
key={`${repo.registryId}-${repo.name}`}
onClick={() => handleRepositoryClick(repo)}
className={`w-full text-left px-8 py-2 flex items-center justify-between text-sm transition ${
isSelected
? "bg-blue-50 text-blue-700"
: "hover:bg-white/80 text-slate-700"
}`}
>
<span className="truncate">{repo.name}</span>
{repo.artifactCount !== undefined && (
<span className="text-xs text-slate-500 flex-shrink-0">
{repo.artifactCount}
</span>
)}
</button>
);
})
)}
</div>
)}
</div>
))
)}
</div>
</>
)}
</aside> </aside>
<main className="flex-1 flex flex-col bg-white overflow-hidden"> <main className="flex-1 flex flex-col bg-white overflow-hidden">
{!selectedRepository ? ( {!selectedRepository ? (
/* Placeholder when no repo is selected */
<div className="flex-1 flex items-center justify-center"> <div className="flex-1 flex items-center justify-center">
<EmptyState <EmptyState
icon={Package} icon={LayoutGrid}
title="Select a repository" title="Select a chart"
description="Choose a chart repository from the left panel." description="Select a chart from the left panel to view versions"
/> />
</div> </div>
) : ( ) : (
<> <>
{/* Right panel header */}
<div className="flex-shrink-0 border-b border-slate-200 p-5 bg-slate-50"> <div className="flex-shrink-0 border-b border-slate-200 p-5 bg-slate-50">
<div className="flex items-center justify-between flex-wrap gap-4"> <div className="flex items-center justify-between flex-wrap gap-4">
<div> <div>
<p className="text-xs uppercase text-slate-500">Chart repository</p> <p className="text-xs uppercase text-slate-500">Chart repository</p>
<h2 className="text-2xl font-semibold text-slate-900"> <h2 className="text-2xl font-semibold text-slate-900 truncate">
{selectedRepository.name} {selectedRepository.name}
</h2> </h2>
<p className="text-sm text-slate-500"> <p className="text-sm text-slate-500">
@ -422,7 +482,27 @@ const ArtifactBrowserPage: React.FC = () => {
</div> </div>
</div> </div>
<div className="flex-1 overflow-y-auto p-5"> {/* Tag search bar */}
<div className="flex-shrink-0 px-5 pt-4 pb-2">
<div className="relative max-w-xs">
<Search className="absolute left-2 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
<input
type="text"
placeholder="Filter tags by version..."
value={tagSearchTerm}
onChange={(e) => 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"
/>
</div>
{tagSearchTerm && (
<p className="text-xs text-slate-500 mt-1.5">
Showing {filteredArtifacts.length} of {artifacts.length} tags
</p>
)}
</div>
{/* Tag grid */}
<div className="flex-1 overflow-y-auto p-5 pt-2">
{artifactError && ( {artifactError && (
<p className="text-sm text-red-400 mb-3">{artifactError}</p> <p className="text-sm text-red-400 mb-3">{artifactError}</p>
)} )}
@ -438,14 +518,21 @@ const ArtifactBrowserPage: React.FC = () => {
: "This repository doesn't contain any tagged artifacts yet." : "This repository doesn't contain any tagged artifacts yet."
} }
/> />
) : filteredArtifacts.length === 0 ? (
<EmptyState
icon={Search}
title="No matching tags"
description={`No tags matching "${tagSearchTerm}" found.`}
/>
) : ( ) : (
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4"> <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3">
{artifacts.map((artifact, index) => ( {filteredArtifacts.map((artifact, index) => (
<TagCard <TagCard
key={`${artifact.repositoryName || "repo"}-${artifact.tag || index}`} key={`${artifact.repositoryName || "repo"}-${artifact.tag || index}`}
registryId={selectedRepository.registryId} registryId={selectedRepository.registryId}
registryUrl={selectedRegistryUrl} registryUrl={selectedRegistryUrl}
tag={artifact} tag={artifact}
isLatest={index === 0}
/> />
))} ))}
</div> </div>

View File

@ -28,3 +28,19 @@
to { opacity: 1; transform: translateY(0); } to { opacity: 1; transform: translateY(0); }
} }
.animate-fadeIn { animation: fadeIn 0.25s ease-out; } .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);
}

View File

@ -53,10 +53,10 @@ export default function SidebarNav({ items = [] as NavItem[], isOpen = true, onC
<div key={item.key}> <div key={item.key}>
<button <button
onClick={() => handleItemClick(item, hasChildren)} onClick={() => handleItemClick(item, hasChildren)}
className={`w-full text-left flex items-center gap-2 px-3 py-2 rounded-xl text-sm font-medium transition-colors duration-200 ${ className={`w-full text-left flex items-center gap-2 px-3 py-2 rounded-xl text-sm font-medium transition-colors duration-150 hover:bg-blue-50 dark:hover:bg-blue-900/20 ${
item.active item.active
? "bg-blue-50 text-blue-700 border border-blue-200 shadow-sm" ? "bg-blue-50 text-blue-700 border border-blue-200 shadow-sm"
: "text-slate-600 hover:text-slate-950 hover:bg-slate-100" : "text-slate-600 hover:text-slate-950"
}`} }`}
style={{ paddingLeft: `${12 + level * 16}px` }} style={{ paddingLeft: `${12 + level * 16}px` }}
> >