Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/deploy-production.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ jobs:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
s3-bucket-name: 'console.qovery.com'
# CloudFront migration (QOV-2108): dual-target until the DNS cutover
s3-bucket-name-with-cloudfront: 'qovery-console-prod'
cloudfront-distribution-id: 'E28PPG1VH7VZ9R'
cloudflare-zone: ${{ secrets.CLOUDFLARE_ZONE }}
cloudflare-token: ${{ secrets.CLOUDFLARE_TOKEN }}
nx-cloud-access-token: ${{ secrets.NX_CLOUD_ACCESS_TOKEN }}
Expand Down
3 changes: 3 additions & 0 deletions .github/workflows/deploy-staging.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ jobs:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
s3-bucket-name: 'console-staging.qovery.com'
# CloudFront migration (QOV-2108): dual-target until the DNS cutover
s3-bucket-name-with-cloudfront: 'qovery-console-staging'
cloudfront-distribution-id: 'EN3DKJ81R0NBQ'
cloudflare-zone: ${{ secrets.CLOUDFLARE_ZONE }}
cloudflare-token: ${{ secrets.CLOUDFLARE_TOKEN }}
nx-cloud-access-token: ${{ secrets.NX_CLOUD_ACCESS_TOKEN }}
Expand Down
25 changes: 25 additions & 0 deletions .github/workflows/test-build-and-deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,12 @@ on:
required: false
s3-bucket-name:
required: false
# CloudFront migration (QOV-2108): origin bucket behind CloudFront.
# When set, builds are also deployed there and index.html is invalidated.
s3-bucket-name-with-cloudfront:
required: false
cloudfront-distribution-id:
required: false
cloudflare-zone:
required: false
cloudflare-token:
Expand Down Expand Up @@ -208,6 +214,9 @@ jobs:
runs-on: ubuntu-latest
needs: [nx-main]
if: ${{ inputs.flow != 'pull-request' }}
env:
S3_BUCKET_NAME_WITH_CLOUDFRONT: ${{ secrets.s3-bucket-name-with-cloudfront }}
CLOUDFRONT_DISTRIBUTION_ID: ${{ secrets.cloudfront-distribution-id }}
steps:
- name: Git clone the repository
uses: actions/checkout@v4
Expand All @@ -222,6 +231,22 @@ jobs:
- name: Copy index.html to S3 last
run: |
AWS_REGION=${{ secrets.aws-region }} AWS_ACCESS_KEY_ID=${{ secrets.aws-access-key-id }} AWS_SECRET_ACCESS_KEY=${{ secrets.aws-secret-access-key }} aws s3 cp ./dist/dist/apps/console/index.html s3://${{ secrets.s3-bucket-name }}/
# Dual-target during the CloudFront migration (QOV-2108): the bucket
# below is the OAC origin behind CloudFront; the legacy bucket above
# keeps serving the live site until the DNS cutover. Remove the legacy
# steps once the migration is complete.
- name: Copy assets to CloudFront origin bucket
if: env.S3_BUCKET_NAME_WITH_CLOUDFRONT != ''
run: |
AWS_REGION=${{ secrets.aws-region }} AWS_ACCESS_KEY_ID=${{ secrets.aws-access-key-id }} AWS_SECRET_ACCESS_KEY=${{ secrets.aws-secret-access-key }} aws s3 cp ./dist/dist/apps/console/ s3://$S3_BUCKET_NAME_WITH_CLOUDFRONT/ --recursive --exclude "index.html"
- name: Copy index.html to CloudFront origin bucket last
if: env.S3_BUCKET_NAME_WITH_CLOUDFRONT != ''
run: |
AWS_REGION=${{ secrets.aws-region }} AWS_ACCESS_KEY_ID=${{ secrets.aws-access-key-id }} AWS_SECRET_ACCESS_KEY=${{ secrets.aws-secret-access-key }} aws s3 cp ./dist/dist/apps/console/index.html s3://$S3_BUCKET_NAME_WITH_CLOUDFRONT/
- name: Invalidate CloudFront cached index.html
if: env.CLOUDFRONT_DISTRIBUTION_ID != ''
run: |
AWS_REGION=${{ secrets.aws-region }} AWS_ACCESS_KEY_ID=${{ secrets.aws-access-key-id }} AWS_SECRET_ACCESS_KEY=${{ secrets.aws-secret-access-key }} aws cloudfront create-invalidation --distribution-id $CLOUDFRONT_DISTRIBUTION_ID --paths "/index.html" "/"

staging-smoke-test:
name: Staging smoke test
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { type IconName } from '@fortawesome/fontawesome-common-types'
import { Outlet, createFileRoute, useMatchRoute } from '@tanstack/react-router'
import { Link as RouterLink } from '@tanstack/react-router'
import posthog from 'posthog-js'
import { useFeatureFlagEnabled } from 'posthog-js/react'
import { useEffect, useMemo } from 'react'
import {
ClusterAvatar,
Expand All @@ -22,7 +23,7 @@ import {
} from '@qovery/domains/environments/feature'
import { useEnvironmentsOverview } from '@qovery/domains/projects/feature'
import { isArgoCd, isEditableService } from '@qovery/domains/services/data-access'
import { ArgoCdServiceList, useServices } from '@qovery/domains/services/feature'
import { AgenticWorkflowServiceList, ArgoCdServiceList, useServices } from '@qovery/domains/services/feature'
import { Heading, Icon, Link, Navbar, Section, Tooltip } from '@qovery/shared/ui'

export const Route = createFileRoute(
Expand All @@ -44,6 +45,7 @@ function RouteComponent() {
const { data: deploymentStatus } = useDeploymentStatus({ environmentId })
const { data: cluster } = useCluster({ organizationId, clusterId: environment?.cluster_id, suspense: true })
const { data: services = [] } = useServices({ environmentId, suspense: true })
const isAgenticWorkflowEnabled = Boolean(useFeatureFlagEnabled('argentic-workflow'))

useClusterRunningStatusSocket({
organizationId,
Expand Down Expand Up @@ -195,6 +197,9 @@ function RouteComponent() {
)}
</div>
{shouldDisplayArgoCdServicesBelowQovery && <ArgoCdServiceList environment={environment} />}
{isServicesListTab && isAgenticWorkflowEnabled && (
<AgenticWorkflowServiceList environmentId={environmentId} />
)}
</div>
</Section>
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1235,6 +1235,7 @@ export const PERSONAL_EMAIL_DOMAINS = new Set([
'globalfree.it',
'globalpagan.com',
'globalsite.com.br',
'gmail.co',
'gmail.com',
'gmail.com.br',
'gmail.ru',
Expand Down Expand Up @@ -3722,6 +3723,7 @@ export const PERSONAL_EMAIL_DOMAINS = new Set([
'promessage.com',
'prontomail.com',
'protestant.com',
'proton.me',
'protonmail.com',
'prydirect.info',
'psv-supporter.com',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ describe('StepPersonalize', () => {
await waitFor(() => expect(continueButton).toBeEnabled())
})

it('should reject personal email addresses', async () => {
it.each(['gmail.com', 'gmail.co', 'proton.me'])('should reject personal email addresses from %s', async (domain) => {
const { userEvent } = renderWithProviders(
wrapWithReactHookForm(<StepPersonalize {...props} />, {
defaultValues: {
Expand All @@ -61,7 +61,7 @@ describe('StepPersonalize', () => {
const continueButton = screen.getByRole('button', { name: 'Continue' })
const emailInput = screen.getByLabelText('Professional email address')

await userEvent.type(emailInput, 'john.doe@gmail.com')
await userEvent.type(emailInput, `john.doe@${domain}`)
await userEvent.tab()

expect(await screen.findByText('Please enter your professional email address.')).toBeInTheDocument()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,10 +47,10 @@ export function StepVariables() {

const [variables, setVariables] = useState(methods.getValues().variables)

const onAddPort = () => {
const onAddPort = (isSecret = false) => {
const newVariableRow: VariableData = {
variable: '',
isSecret: false,
isSecret,
value: '',
scope: APIVariableScopeEnum.JOB,
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { createQueryKeys, type inferQueryKeys } from '@lukemorales/query-key-factory'
import {
type AgenticWorkflowResponse,
AgenticWorkflowsApi,
ApplicationActionsApi,
type ApplicationAdvancedSettings,
ApplicationConfigurationApi,
Expand Down Expand Up @@ -104,6 +106,7 @@ const helmsApi = new HelmsApi()
const terraformsApi = new TerraformsApi()
const blueprintApi = new BlueprintMainCallsApi()
const blueprintCatalogApi = new BlueprintCatalogApi()
const agenticWorkflowsApi = new AgenticWorkflowsApi()

const applicationMainCallsApi = new ApplicationMainCallsApi()
const containerMainCallsApi = new ContainerMainCallsApi()
Expand Down Expand Up @@ -238,7 +241,7 @@ export function isArgoCd(service?: AnyService): service is ArgoCd {
}

export function isEditableService(service: AnyService): service is EditableService {
return !isArgoCd(service)
return !isArgoCd(service) && (service.service_type as string) !== 'AGENTIC_WORKFLOW'
}

export function isEditableServiceType(serviceType?: ServiceType): serviceType is EditableServiceType {
Expand Down Expand Up @@ -365,6 +368,13 @@ export const services = createQueryKeys('services', {
)
},
}),
listAgenticWorkflows: (environmentId: string) => ({
queryKey: [environmentId],
async queryFn(): Promise<AgenticWorkflowResponse[]> {
const response = await agenticWorkflowsApi.listAgenticWorkflows(environmentId)
return response.data.results ?? []
},
}),
argocdManifest: (serviceId: string) => ({
queryKey: [serviceId],
async queryFn() {
Expand Down
2 changes: 2 additions & 0 deletions libs/domains/services/feature/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,8 @@ export * from './lib/hooks/use-recent-services/use-recent-services'
export * from './lib/hooks/use-favorite-services/use-favorite-services'
export * from './lib/service-actions/service-actions'
export * from './lib/argocd-service-list/argocd-service-list'
export * from './lib/agentic-workflow-service-list/agentic-workflow-service-list'
export * from './lib/hooks/use-agentic-workflow-services/use-agentic-workflow-services'
export * from './lib/argocd-manifest/argocd-manifest'
export * from './lib/service-deployment-status-label/service-deployment-status-label'
export * from './lib/service-overview/service-header/service-header'
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { renderWithProviders, screen } from '@qovery/shared/util-tests'
import { AgenticWorkflowServiceList } from './agentic-workflow-service-list'

const mockUseAgenticWorkflowServices = jest.fn()

jest.mock('../hooks/use-agentic-workflow-services/use-agentic-workflow-services', () => ({
useAgenticWorkflowServices: () => mockUseAgenticWorkflowServices(),
}))

describe('AgenticWorkflowServiceList', () => {
it('should not render an empty section', () => {
mockUseAgenticWorkflowServices.mockReturnValue({ data: [] })
const { container } = renderWithProviders(<AgenticWorkflowServiceList environmentId="environment-1" />)

expect(container).toBeEmptyDOMElement()
})

it('should render agentic workflows in their own section', () => {
mockUseAgenticWorkflowServices.mockReturnValue({
data: [
{ id: 'workflow-1', name: 'Review pull requests', enabled: true, model: { type: 'CLAUDE' } },
{ id: 'workflow-2', name: 'Triage incidents', enabled: false, model: { type: 'OPENAI' } },
],
})
renderWithProviders(<AgenticWorkflowServiceList environmentId="environment-1" />)

expect(screen.getByRole('heading', { name: 'Agentic workflows' })).toBeInTheDocument()
expect(screen.getByText('Review pull requests')).toBeInTheDocument()
expect(screen.getByText('Triage incidents')).toBeInTheDocument()
expect(screen.getByText('1 enabled')).toBeInTheDocument()
expect(screen.getByText('CLAUDE')).toBeInTheDocument()
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { type AgenticWorkflowResponse } from 'qovery-typescript-axios'
import { Badge, Heading, Icon, Section, TablePrimitives, Tooltip } from '@qovery/shared/ui'
import { useAgenticWorkflowServices } from '../hooks/use-agentic-workflow-services/use-agentic-workflow-services'

const { Table } = TablePrimitives

const tableGridLayoutClassName = 'grid w-full grid-cols-[minmax(350px,1fr)_minmax(180px,260px)_140px]'

export interface AgenticWorkflowServiceListProps {
environmentId: string
}

export function AgenticWorkflowServiceList({ environmentId }: AgenticWorkflowServiceListProps) {
const { data: services = [] } = useAgenticWorkflowServices({ environmentId, suspense: true })

if (services.length === 0) return null

const enabledServicesCount = services.filter(({ enabled }) => enabled).length

return (
<Section className="flex flex-col gap-3.5">
<div className="flex flex-col gap-1">
<Heading level={3} className="font-medium text-neutral-subtle">
Agentic workflows
</Heading>
<p className="text-sm text-neutral-subtle">AI workflows triggered through webhooks and connected services.</p>
</div>

<div className="flex flex-col overflow-hidden rounded-lg border border-neutral">
<div className="flex gap-1.5 bg-surface-neutral px-4 py-3">
<Badge variant="surface" radius="full" color="green" className="text-ssm font-medium">
{enabledServicesCount} enabled
</Badge>
</div>
<Table.Root
containerClassName="rounded-none border-x-0 border-b-0 border-t"
className="w-full min-w-[700px] overflow-x-scroll text-xs xl:overflow-auto"
>
<Table.Header className="border-neutral">
<Table.Row className={`h-9 w-full ${tableGridLayoutClassName}`}>
<Table.ColumnHeaderCell className="flex h-full items-center border-r border-neutral text-neutral-subtle">
Service
</Table.ColumnHeaderCell>
<Table.ColumnHeaderCell className="flex h-full items-center border-r border-neutral text-neutral-subtle">
Model
</Table.ColumnHeaderCell>
<Table.ColumnHeaderCell className="flex h-full items-center text-neutral-subtle">
Status
</Table.ColumnHeaderCell>
</Table.Row>
</Table.Header>
<Table.Body>
{services.map((service: AgenticWorkflowResponse) => (
<Table.Row key={service.id} className={`h-[60px] w-full ${tableGridLayoutClassName}`}>
<Table.Cell className="flex h-full min-w-0 items-center border-r border-neutral">
<div className="flex min-w-0 items-center gap-3 text-sm font-medium">
<Icon iconName="brain-circuit" className="shrink-0 text-neutral" />
<Tooltip content={service.name}>
<span className="truncate text-neutral">{service.name}</span>
</Tooltip>
</div>
</Table.Cell>
<Table.Cell className="flex h-full items-center border-r border-neutral text-sm text-neutral">
{service.model?.type ?? '-'}
</Table.Cell>
<Table.Cell className="flex h-full items-center">
<Badge variant="surface" color={service.enabled ? 'green' : 'neutral'}>
{service.enabled ? 'Enabled' : 'Disabled'}
</Badge>
</Table.Cell>
</Table.Row>
))}
</Table.Body>
</Table.Root>
</div>
</Section>
)
}

export default AgenticWorkflowServiceList
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { useQuery } from '@tanstack/react-query'
import { queries } from '@qovery/state/util-queries'

export interface UseAgenticWorkflowServicesProps {
environmentId: string
suspense?: boolean
}

export function useAgenticWorkflowServices({ environmentId, suspense = false }: UseAgenticWorkflowServicesProps) {
return useQuery({
...queries.services.listAgenticWorkflows(environmentId),
suspense,
})
}

export default useAgenticWorkflowServices
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ jest.mock('@qovery/domains/variables/feature', () => ({
VariableFormModal: ({
onSubmit,
scope,
isFile,
isSecret,
}: {
onSubmit?: (data: {
key: string
Expand All @@ -51,18 +51,17 @@ jest.mock('@qovery/domains/variables/feature', () => ({
mountPath?: string
}) => void
scope: 'APPLICATION' | 'CONTAINER'
isFile?: boolean
isSecret?: boolean
}) => (
<button
type="button"
onClick={() =>
onSubmit?.({
key: isFile ? 'CONFIG_FILE' : 'NODE_ENV',
value: isFile ? '{"key":"value"}' : 'production',
key: 'NODE_ENV',
value: 'production',
scope,
isSecret: false,
isFile: !!isFile,
mountPath: isFile ? '/vault/secrets/config-file' : undefined,
isSecret: !!isSecret,
isFile: false,
})
}
>
Expand Down Expand Up @@ -113,6 +112,28 @@ describe('ApplicationContainerStepVariables', () => {
})
})

it('adds a secret with application scope by default', async () => {
const { userEvent } = renderWithProviders(
<ApplicationContainerCreationFlow
creationFlowUrl="/organization/org-1/project/proj-1/environment/env-1/service/create/application"
defaultServiceType="APPLICATION"
>
<>
<ApplicationContainerStepVariables onBack={onBack} onSubmit={onSubmit} />
<VariablesState />
</>
</ApplicationContainerCreationFlow>
)

await userEvent.click(screen.getByRole('button', { name: /^add secret$/i }))
await userEvent.click(screen.getByRole('button', { name: /confirm variable modal/i }))

await waitFor(() => {
expect(screen.getByTestId('variables-state')).toHaveTextContent('"scope":"APPLICATION"')
expect(screen.getByTestId('variables-state')).toHaveTextContent('"isSecret":true')
})
})

it('calls onBack when going back', async () => {
const { userEvent } = renderWithProviders(
<ApplicationContainerCreationFlow
Expand Down Expand Up @@ -154,7 +175,6 @@ describe('ApplicationContainerStepVariables', () => {
)

expect(screen.getByText('No secret manager linked on your cluster')).toBeInTheDocument()
expect(screen.queryByRole('button', { name: /^add secret$/i })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: /^add secret as file$/i })).not.toBeInTheDocument()
})
})
Loading
Loading