Skip to content
Draft
6 changes: 6 additions & 0 deletions libs/domains/clusters/feature/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,12 @@ export * from './lib/hooks/use-edit-cluster-kubeconfig/use-edit-cluster-kubeconf
export * from './lib/hooks/use-cluster-logs/use-cluster-logs'
export * from './lib/hooks/use-cluster-status/use-cluster-status'
export * from './lib/hooks/use-cluster-statuses/use-cluster-statuses'
export * from './lib/hooks/use-active-deployment-clusters/use-active-deployment-clusters'
export * from './lib/hooks/use-cluster/use-cluster'
export * from './lib/hooks/use-clusters/use-clusters'
export * from './lib/hooks/use-cluster-advanced-settings/use-cluster-advanced-settings'
export * from './lib/hooks/use-default-advanced-settings/use-default-advanced-settings'
export * from './lib/utils/cluster-install-tracking'
export * from './lib/hooks/use-download-kubeconfig/use-download-kubeconfig'
export * from './lib/hooks/use-create-cluster/use-create-cluster'
export * from './lib/hooks/use-edit-cloud-provider-info/use-edit-cloud-provider-info'
Expand All @@ -38,5 +40,9 @@ export * from './lib/hooks/use-upgrade-cluster/use-upgrade-cluster'
export * from './lib/hooks/use-update-karpenter-private-fargate/use-update-karpenter-private-fargate'
export * from './lib/hooks/use-cluster-running-status/use-cluster-running-status'
export * from './lib/hooks/use-cluster-running-status-socket/use-cluster-running-status-socket'
export * from './lib/hooks/use-deployment-progress/use-deployment-progress'
export * from './lib/cluster-deployment-progress/cluster-deployment-progress-card/cluster-deployment-progress-card'
export * from './lib/cluster-deployment-progress/cluster-notification-permission-modal/cluster-notification-permission-modal'
export * from './lib/cluster-deployment-progress/cluster-notification-permission-modal/use-cluster-install-notifications'
export * from './lib/gpu-resources-settings/gpu-resources-settings'
export * from './lib/utils/has-gpu-instance'
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ import { useDownloadKubeconfig } from '../hooks/use-download-kubeconfig/use-down
import { useStopCluster } from '../hooks/use-stop-cluster/use-stop-cluster'
import { useUpgradeCluster } from '../hooks/use-upgrade-cluster/use-upgrade-cluster'

export const SHOW_SELF_MANAGED_GUIDE_KEY = 'show-self-managed-guide'

function MenuManageDeployment({ cluster, clusterStatus }: { cluster: Cluster; clusterStatus: ClusterStatus }) {
const { openModalConfirmation } = useModalConfirmation()
const { openModal } = useModal()
Expand Down Expand Up @@ -263,7 +265,6 @@ export interface ClusterActionToolbarProps {
export function ClusterActionToolbar({ cluster, clusterStatus }: ClusterActionToolbarProps) {
const navigate = useNavigate()
const { pathname } = useLocation()
const showSelfManagedGuideKey = 'show-self-managed-guide'
const [searchParams, setSearchParams] = useSearchParams()
const { openModal, closeModal } = useModal()
const { data: runningStatus } = useClusterRunningStatus({
Expand All @@ -282,7 +283,7 @@ export function ClusterActionToolbar({ cluster, clusterStatus }: ClusterActionTo
cluster={cluster}
type={type}
onClose={() => {
searchParams.delete(showSelfManagedGuideKey)
searchParams.delete(SHOW_SELF_MANAGED_GUIDE_KEY)
setSearchParams(searchParams)
closeModal()
}}
Expand All @@ -291,9 +292,9 @@ export function ClusterActionToolbar({ cluster, clusterStatus }: ClusterActionTo
})

useEffect(() => {
const bool = searchParams.has(showSelfManagedGuideKey) && cluster.kubernetes === 'SELF_MANAGED'
const bool = searchParams.has(SHOW_SELF_MANAGED_GUIDE_KEY) && cluster.kubernetes === 'SELF_MANAGED'
if (bool) {
searchParams.delete(showSelfManagedGuideKey)
searchParams.delete(SHOW_SELF_MANAGED_GUIDE_KEY)
setSearchParams(searchParams)
openInstallationGuideModal()
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import { type Cluster, type Project } from 'qovery-typescript-axios'
import { useProjects } from '@qovery/domains/projects/feature'
import { renderWithProviders, screen } from '@qovery/shared/util-tests'
import { useDeploymentProgress } from '../../hooks/use-deployment-progress/use-deployment-progress'
import ClusterDeploymentProgressCard, {
type ClusterDeploymentProgressCardProps,
} from './cluster-deployment-progress-card'

jest.mock('@qovery/domains/projects/feature', () => ({
...jest.requireActual('@qovery/domains/projects/feature'),
useProjects: jest.fn(),
}))

jest.mock('../../hooks/use-deployment-progress/use-deployment-progress', () => ({
...jest.requireActual('../../hooks/use-deployment-progress/use-deployment-progress'),
useDeploymentProgress: jest.fn(),
}))

const mockUseProjects = useProjects as jest.MockedFunction<typeof useProjects>
const mockUseDeploymentProgress = useDeploymentProgress as jest.MockedFunction<typeof useDeploymentProgress>

describe('ClusterDeploymentProgressCard', () => {
const mockProject: Project = {
id: 'project-1',
name: 'Test Project',
organization: { id: 'org-1' },
} as Project

const mockCluster: Cluster = {
id: 'cluster-1',
name: 'Test Cluster',
organization: { id: 'org-1' },
cloud_provider: 'AWS',
} as Cluster

const props: ClusterDeploymentProgressCardProps = {
organizationId: 'org-1',
clusters: [mockCluster],
}

beforeEach(() => {
mockUseProjects.mockReturnValue({
data: [mockProject],
isLoading: false,
error: null,
} as ReturnType<typeof useProjects>)

mockUseDeploymentProgress.mockReturnValue({
steps: [
{ label: 'Validating configuration', status: 'done' },
{ label: 'Providing infrastructure (on provider side)', status: 'current' },
{ label: 'Verifying provided infrastructure', status: 'pending' },
{ label: 'Installing Qovery stack', status: 'pending' },
{ label: 'Verifying kube deprecation API calls', status: 'pending' },
],
installationComplete: false,
highestStepIndex: 1,
progressValue: 0.4,
currentStepLabel: 'Providing infrastructure (on provider side)',
creationFailed: false,
state: 'installing',
justSucceeded: false,
justFailed: false,
})
})

afterEach(() => {
jest.clearAllMocks()
})

it('should render successfully', () => {
const { baseElement } = renderWithProviders(<ClusterDeploymentProgressCard {...props} />)
expect(baseElement).toBeTruthy()
})

it('should render cluster name when installing', () => {
renderWithProviders(<ClusterDeploymentProgressCard {...props} />)
expect(screen.getByText('Test Cluster')).toBeInTheDocument()
})

it('should render current step label when installing', () => {
renderWithProviders(<ClusterDeploymentProgressCard {...props} />)
expect(screen.getByText('Providing infrastructure (on provider side)')).toBeInTheDocument()
})

it('should render success state', () => {
mockUseDeploymentProgress.mockReturnValue({
steps: [
{ label: 'Validating configuration', status: 'done' },
{ label: 'Providing infrastructure (on provider side)', status: 'done' },
{ label: 'Verifying provided infrastructure', status: 'done' },
{ label: 'Installing Qovery stack', status: 'done' },
{ label: 'Verifying kube deprecation API calls', status: 'done' },
],
installationComplete: true,
highestStepIndex: 4,
progressValue: 1,
currentStepLabel: 'Verifying kube deprecation API calls',
creationFailed: false,
state: 'succeeded',
justSucceeded: false,
justFailed: false,
})

renderWithProviders(<ClusterDeploymentProgressCard {...props} />)
expect(screen.getByText(/Test Cluster.*created/)).toBeInTheDocument()
expect(screen.getByText('Start deploying')).toBeInTheDocument()
})

it('should render failed state', () => {
mockUseDeploymentProgress.mockReturnValue({
steps: [
{ label: 'Validating configuration', status: 'done' },
{ label: 'Providing infrastructure (on provider side)', status: 'done' },
{ label: 'Verifying provided infrastructure', status: 'current' },
{ label: 'Installing Qovery stack', status: 'pending' },
{ label: 'Verifying kube deprecation API calls', status: 'pending' },
],
installationComplete: false,
highestStepIndex: 2,
progressValue: 0.6,
currentStepLabel: 'Verifying provided infrastructure',
creationFailed: true,
state: 'failed',
justSucceeded: false,
justFailed: false,
})

renderWithProviders(<ClusterDeploymentProgressCard {...props} />)
expect(screen.getByText(/Test Cluster.*creation failed/)).toBeInTheDocument()
expect(screen.getByText('See logs')).toBeInTheDocument()
})

it('should return null when clusters array is empty', () => {
const { container } = renderWithProviders(<ClusterDeploymentProgressCard {...props} clusters={[]} />)
expect(container).toBeEmptyDOMElement()
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
import * as AccordionPrimitive from '@radix-ui/react-accordion'
import clsx from 'clsx'
import { type Cluster, type Project } from 'qovery-typescript-axios'
import { match } from 'ts-pattern'
import { useProjects } from '@qovery/domains/projects/feature'
import { INFRA_LOGS_URL, OVERVIEW_URL } from '@qovery/shared/routes'
import { AnimatedGradientText, Icon, Link } from '@qovery/shared/ui'
import { twMerge } from '@qovery/shared/util-js'
import { useDeploymentProgress } from '../../hooks/use-deployment-progress/use-deployment-progress'

export interface ClusterDeploymentProgressCardProps {
organizationId: string
clusters: Cluster[]
}

export function ClusterDeploymentProgressCard({ organizationId, clusters }: ClusterDeploymentProgressCardProps) {
const { data: projects = [] } = useProjects({ organizationId })

if (!clusters.length) return null

return (
<div className="fixed bottom-[80px] right-4 w-96 max-w-full overflow-hidden rounded-xl border border-neutral-200 bg-neutral-100 shadow-md">
<AccordionPrimitive.Root type="multiple" className="w-full">
{clusters.map((cluster) => (
<Item key={cluster.id} cluster={cluster} project={projects[0]} />
))}
</AccordionPrimitive.Root>
</div>
)
}

function Item({ cluster, project }: { cluster: Cluster; project: Project }) {
const { steps, progressValue, currentStepLabel, state } = useDeploymentProgress({
organizationId: cluster.organization.id,
clusterId: cluster.id,
clusterName: cluster.name,
cloudProvider: cluster.cloud_provider,
})

const { link, label } = match(state)
.with('failed', () => ({
link: INFRA_LOGS_URL(cluster.organization.id, cluster.id),
label: 'See logs' as const,
}))
.with('succeeded', () =>
project
? {
link: OVERVIEW_URL(cluster.organization.id, project.id),
label: 'Start deploying' as const,
}
: { link: undefined, label: undefined }
)
.otherwise(() => ({ link: undefined, label: undefined }))

const isInstalling = state === 'installing'
const isFailed = state === 'failed'
const isSucceeded = state === 'succeeded'
const isDone = isFailed || isSucceeded

const statusText = match(state)
.with('failed', () => 'creation failed')
.with('succeeded', () => 'created')
.otherwise(() => '')

if (isInstalling && !isDone) {
return (
<AccordionPrimitive.Item
value={cluster.id}
className="overflow-hidden border-0 [&:not(:last-child)]:border-b [&:not(:last-child)]:border-neutral-200"
>
<AccordionPrimitive.Trigger className="group relative flex w-full items-center justify-between gap-4 overflow-hidden bg-white p-4 text-sm shadow-sm outline-none [&:only-child]:rounded-xl">
<div className="flex shrink-0 items-center gap-2 text-neutral-400">
<span aria-hidden="true" className="inline-flex h-[14px] w-[14px] items-center justify-center">
<svg
className="relative top-[1px] -rotate-90"
width="14"
height="14"
viewBox="0 0 14 14"
role="presentation"
>
<circle
cx="7"
cy="7"
r={6.25}
stroke="var(--color-neutral-200)"
strokeWidth={1.5}
fill="none"
strokeLinecap="round"
/>
<circle
cx="7"
cy="7"
r={6.25}
stroke="var(--color-brand-500)"
strokeWidth={1.5}
fill="none"
strokeLinecap="round"
strokeDasharray={2 * Math.PI * 6.25}
strokeDashoffset={2 * Math.PI * 6.25 * (1 - progressValue)}
className="transition-[stroke-dashoffset] duration-300 ease-out"
/>
</svg>
</span>
<span className="truncate">{cluster.name}</span>
</div>
<div className="flex min-w-0 items-center gap-1.5">
<span className="min-w-0 overflow-hidden">
<AnimatedGradientText className="block w-full truncate from-neutral-300 via-neutral-350 to-neutral-300 text-left text-ssm">
{currentStepLabel}
</AnimatedGradientText>
</span>
<Icon
iconName="chevron-down"
iconStyle="regular"
className="text-xs transition-transform duration-200 ease-[cubic-bezier(0.87,_0,_0.13,_1)] group-data-[state=open]:rotate-180"
/>
</div>
</AccordionPrimitive.Trigger>
<AccordionPrimitive.Content className="data-[state=closed]:slidein-up-sm-faded overflow-hidden bg-neutral-100 px-4 py-3 text-ssm data-[state=open]:animate-slidein-down-sm-faded">
<ul className="flex flex-col gap-2">
{steps.map(({ label, status }) => (
<li key={label} className="flex items-center gap-2">
{status === 'done' && <Icon iconName="check-circle" iconStyle="regular" className="text-neutral-350" />}
{status === 'current' && (
<Icon iconName="loader" iconStyle="regular" className="animate-spin text-neutral-400" />
)}
{status === 'pending' && <Icon iconName="circle" iconStyle="regular" className="text-neutral-300" />}
<span
className={twMerge(
clsx(
status === 'done' && 'text-neutral-350',
status === 'current' && 'text-neutral-400',
status === 'pending' && 'text-neutral-300'
)
)}
>
{label}
</span>
</li>
))}
</ul>
</AccordionPrimitive.Content>
</AccordionPrimitive.Item>
)
}

return (
<div className="[&:not(:last-child)]:border-b [&:not(:last-child)]:border-neutral-200">
<div className="relative flex w-full items-center justify-between gap-4 overflow-hidden bg-white p-4 text-sm shadow-sm [&:only-child]:rounded-xl">
<div className="flex shrink-0 items-center gap-2 text-neutral-400">
{isSucceeded && <Icon iconName="check-circle" iconStyle="regular" className="text-green-500" />}
{isFailed && <Icon iconName="circle-xmark" iconStyle="regular" className="text-red-500" />}
{!isSucceeded && !isFailed && !isInstalling && (
<Icon iconName="clock" iconStyle="regular" className="text-neutral-350" />
)}
<span className="truncate">
{cluster.name} {statusText}
</span>
</div>
<div className="flex min-w-0 items-center gap-3">
{link && label && (
<Link to={link} size="ssm" color="current" className="text-neutral-350 hover:text-neutral-400">
{label}
<Icon iconName="chevron-right" iconStyle="regular" />
</Link>
)}
{!isSucceeded && !isFailed && !isInstalling && (
<p className="text-ssm text-neutral-350">Deployment queued…</p>
)}
</div>
</div>
</div>
)
}

export default ClusterDeploymentProgressCard
Loading
Loading