feat: scale instances, --reuse-values, values diff, UI redesign, hover animations

Backend (Phase 1):
- Add ScaleInstance endpoint (POST /clusters/{id}/instances/{id}/scale)
- Add GetInstanceValuesDiff endpoint (GET .../values-diff)
- Enable ReuseValues=true in Helm Upgrade for --reuse-values behavior
- Add GetValues/GetChartDefaultValues to HelmClient interface
- Add ScaleInstanceRequest/Response and InstanceValuesDiffResponse DTOs

Frontend (Phase 2):
- InstanceCard: +/- scale buttons with loading spinner
- ModifyModal: values diff view (current vs defaults), Use Defaults button
- ArtifactBrowserPage: collapsible sidebar, compact tag grid, search filter
- TagCard: "LATEST" badge, compact layout, responsive design
- InstanceCard: compact 3-column layout, fewer scrolls needed
- InstancesManagementPage: 3-column grid, compact view
- Global hover-lift and hover-glow CSS utilities
- SidebarNav: subtle hover transition on links
This commit is contained in:
Ivan087
2026-05-13 11:51:24 +08:00
parent 87eaaa564b
commit 28ecb2e636
16 changed files with 715 additions and 235 deletions

View File

@ -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<string | null>(null);
const [filter, setFilter] = useState<ListArtifactsFilter | undefined>("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 = () => {
</div>
</div>
<div className="flex-1 flex overflow-hidden bg-slate-50">
<aside className="w-80 border-r border-slate-200 bg-white flex flex-col">
<div className="p-4 border-b border-slate-200 space-y-2">
<div className="relative">
<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>
{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">
{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>
)}
{/* Collapsible side panel */}
<aside
className={`border-r border-slate-200 bg-white flex flex-col transition-all duration-200 ${
sidebarCollapsed ? "w-12" : "w-80"
}`}
>
{sidebarCollapsed ? (
/* Collapsed state: narrow strip with just a toggle */
<div className="flex flex-col items-center pt-3 gap-4">
<button
onClick={() => setSidebarCollapsed(false)}
className="p-1.5 hover:bg-slate-100 rounded-md transition"
title="Expand sidebar"
>
<ChevronRight className="w-4 h-4 text-slate-500" />
</button>
{registryNodes.slice(0, 5).map((node) => (
<div
key={node.registry.id || node.registry.name}
className="w-7 h-7 rounded-md bg-blue-50 flex items-center justify-center"
title={node.registry.name || "Registry"}
>
<Database className="w-3.5 h-3.5 text-blue-600" />
</div>
))
)}
</div>
))}
{registryNodes.length > 5 && (
<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>
<main className="flex-1 flex flex-col bg-white overflow-hidden">
{!selectedRepository ? (
/* Placeholder when no repo is selected */
<div className="flex-1 flex items-center justify-center">
<EmptyState
icon={Package}
title="Select a repository"
description="Choose a chart repository from the left panel."
icon={LayoutGrid}
title="Select a chart"
description="Select a chart from the left panel to view versions"
/>
</div>
) : (
<>
{/* Right panel header */}
<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>
<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}
</h2>
<p className="text-sm text-slate-500">
@ -422,7 +482,27 @@ const ArtifactBrowserPage: React.FC = () => {
</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 && (
<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."
}
/>
) : 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">
{artifacts.map((artifact, index) => (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3">
{filteredArtifacts.map((artifact, index) => (
<TagCard
key={`${artifact.repositoryName || "repo"}-${artifact.tag || index}`}
registryId={selectedRepository.registryId}
registryUrl={selectedRegistryUrl}
tag={artifact}
isLatest={index === 0}
/>
))}
</div>