From a363431820b0fc3bce3a32e70d4832774578ddda Mon Sep 17 00:00:00 2001 From: Dubroeucqg Date: Thu, 30 Jul 2026 14:32:33 +0200 Subject: [PATCH 1/5] feat(ci): deploy staging to CloudFront origin bucket (QOV-2108) (#2839) Dual-target deploy during the CloudFront migration: staging builds now also publish to qovery-console-staging (the OAC origin behind CloudFront) and invalidate the cached index.html. The legacy bucket keeps serving console-staging.qovery.com until the DNS cutover. Production is intentionally unchanged; it opts in later by passing s3-bucket-name-with-cloudfront and cloudfront-distribution-id. Requires the frontend-code-push IAM policy update (s3:PutObject on the new bucket + cloudfront:CreateInvalidation) before merge. --- .github/workflows/deploy-staging.yml | 3 +++ .github/workflows/test-build-and-deploy.yml | 25 +++++++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/.github/workflows/deploy-staging.yml b/.github/workflows/deploy-staging.yml index b855827ce2c..5174c8b6567 100644 --- a/.github/workflows/deploy-staging.yml +++ b/.github/workflows/deploy-staging.yml @@ -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 }} diff --git a/.github/workflows/test-build-and-deploy.yml b/.github/workflows/test-build-and-deploy.yml index 6991fdac216..25e201a8e7c 100644 --- a/.github/workflows/test-build-and-deploy.yml +++ b/.github/workflows/test-build-and-deploy.yml @@ -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: @@ -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 @@ -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 From 8ccaf716ef1d6f579d920b10744a280472cf6e89 Mon Sep 17 00:00:00 2001 From: Guillaume Dubroeucq Date: Thu, 30 Jul 2026 15:49:46 +0200 Subject: [PATCH 2/5] feat(ci): deploy production to CloudFront origin bucket (QOV-2108) Production releases now also publish to qovery-console-prod (the OAC origin behind CloudFront) and invalidate the cached index.html, using the dual-target support already merged for staging. The legacy bucket keeps serving console.qovery.com until the DNS cutover. IAM (frontend-code-push) already grants both buckets and distributions. --- .github/workflows/deploy-production.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/deploy-production.yml b/.github/workflows/deploy-production.yml index a66bb457879..ab52ca5ae5c 100644 --- a/.github/workflows/deploy-production.yml +++ b/.github/workflows/deploy-production.yml @@ -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 }} From 0fdf617d23cc3e1c5ce570e8a91b89cbc2cc826b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9mi=20Bonnet?= Date: Thu, 30 Jul 2026 18:38:31 +0200 Subject: [PATCH 3/5] fix(onboarding): restrict additional personal email domains (#2842) --- .../src/lib/step-personalize/personal-email-domains.ts | 2 ++ .../src/lib/step-personalize/step-personalize.spec.tsx | 4 ++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/libs/domains/onboarding/feature/src/lib/step-personalize/personal-email-domains.ts b/libs/domains/onboarding/feature/src/lib/step-personalize/personal-email-domains.ts index f8a6f8cb544..e71c03043a4 100644 --- a/libs/domains/onboarding/feature/src/lib/step-personalize/personal-email-domains.ts +++ b/libs/domains/onboarding/feature/src/lib/step-personalize/personal-email-domains.ts @@ -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', @@ -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', diff --git a/libs/domains/onboarding/feature/src/lib/step-personalize/step-personalize.spec.tsx b/libs/domains/onboarding/feature/src/lib/step-personalize/step-personalize.spec.tsx index 492e10cf497..a07301569d1 100644 --- a/libs/domains/onboarding/feature/src/lib/step-personalize/step-personalize.spec.tsx +++ b/libs/domains/onboarding/feature/src/lib/step-personalize/step-personalize.spec.tsx @@ -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(, { defaultValues: { @@ -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() From e687df5a4b11c541acece0b6293bc4964375132c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20Grandin?= Date: Fri, 31 Jul 2026 10:29:42 +0200 Subject: [PATCH 4/5] feat(variables): separate secret creation flow (#2832) * feat(variables): separate secret creation flow * fix(variables): mask secret values on edit * fix(variables): prefill file path on focus * chore(variables): inline creation flow actions * chore(variables): rely on button spacing * chore(variables): simplify variable modal state * fix(variables): remove path placeholder * fix(variables): use file title wording * chore(variables): simplify variable creation actions --- .../step-variables/step-variables.tsx | 4 +- .../step-variables/step-variables.spec.tsx | 36 ++++- .../step-variables/step-variables.tsx | 82 ++++++++-- .../service-variables-custom-tab.tsx | 39 ++--- .../create-update-variable-modal.spec.tsx | 87 ++++++++++- .../create-update-variable-modal.tsx | 147 ++++++++++++------ .../flow-create-variable.tsx | 19 ++- .../variable-row/variable-row.tsx | 10 +- .../src/lib/variable-list/variable-list.tsx | 40 ++--- .../variables-action-toolbar.tsx | 32 ++-- .../input-text-area/input-text-area.tsx | 8 +- 11 files changed, 330 insertions(+), 174 deletions(-) diff --git a/libs/domains/service-job/feature/src/lib/job-creation-flow/step-variables/step-variables.tsx b/libs/domains/service-job/feature/src/lib/job-creation-flow/step-variables/step-variables.tsx index 3ad84d0f57a..1ac9b10a4b1 100644 --- a/libs/domains/service-job/feature/src/lib/job-creation-flow/step-variables/step-variables.tsx +++ b/libs/domains/service-job/feature/src/lib/job-creation-flow/step-variables/step-variables.tsx @@ -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, } diff --git a/libs/domains/services/feature/src/lib/service-creation-flow/application-container/application-container-variables/step-variables/step-variables.spec.tsx b/libs/domains/services/feature/src/lib/service-creation-flow/application-container/application-container-variables/step-variables/step-variables.spec.tsx index ced31df16d4..1df02555b3b 100644 --- a/libs/domains/services/feature/src/lib/service-creation-flow/application-container/application-container-variables/step-variables/step-variables.spec.tsx +++ b/libs/domains/services/feature/src/lib/service-creation-flow/application-container/application-container-variables/step-variables/step-variables.spec.tsx @@ -40,7 +40,7 @@ jest.mock('@qovery/domains/variables/feature', () => ({ VariableFormModal: ({ onSubmit, scope, - isFile, + isSecret, }: { onSubmit?: (data: { key: string @@ -51,18 +51,17 @@ jest.mock('@qovery/domains/variables/feature', () => ({ mountPath?: string }) => void scope: 'APPLICATION' | 'CONTAINER' - isFile?: boolean + isSecret?: boolean }) => ( + + + )}
{variables.length === 0 ? ( - emptyState({ - title: 'No custom variables added yet', - onAddDefault: () => openVariableModal({ isFile: false }), - onAddAsFile: () => openVariableModal({ isFile: true }), - defaultLabel: 'Add variable', - asFileLabel: 'Add variable as file', - }) + +
+ + +
+
) : ( <>
@@ -342,7 +390,7 @@ export function ApplicationContainerStepVariables({ onBack, onSubmit }: Applicat size="xs" iconOnly disabled={variable.isReadOnly} - onClick={() => openVariableModal({ isFile: !!variable.file, index })} + onClick={() => openVariableModal({ index })} > diff --git a/libs/domains/services/feature/src/lib/service-variables-tabs/service-variables-custom-tab.tsx b/libs/domains/services/feature/src/lib/service-variables-tabs/service-variables-custom-tab.tsx index fa061ad9453..d2fce29e1a6 100644 --- a/libs/domains/services/feature/src/lib/service-variables-tabs/service-variables-custom-tab.tsx +++ b/libs/domains/services/feature/src/lib/service-variables-tabs/service-variables-custom-tab.tsx @@ -42,7 +42,7 @@ export function ServiceVariablesCustomTab() { return null } - const handleOpenCreateVariableModal = (isFile = false) => + const handleOpenCreateVariableModal = (isSecret = false) => openModal({ content: (
- - - - - - handleOpenCreateVariableModal()} icon={}> - Variable - - handleOpenCreateVariableModal(true)} - icon={} - > - Variable as file - - - + + + @@ -122,12 +111,6 @@ export function ServiceVariablesCustomTab() { }> Import from .env file - window.open('https://dashboard.doppler.com', '_blank')} - icon={} - > - Import from Doppler -
diff --git a/libs/domains/variables/feature/src/lib/create-update-variable-modal/create-update-variable-modal.spec.tsx b/libs/domains/variables/feature/src/lib/create-update-variable-modal/create-update-variable-modal.spec.tsx index 48210cb1606..9f23681d17c 100644 --- a/libs/domains/variables/feature/src/lib/create-update-variable-modal/create-update-variable-modal.spec.tsx +++ b/libs/domains/variables/feature/src/lib/create-update-variable-modal/create-update-variable-modal.spec.tsx @@ -1,7 +1,7 @@ import { type VariableResponse } from 'qovery-typescript-axios' import { type ReactNode } from 'react' -import { renderWithProviders, screen } from '@qovery/shared/util-tests' -import { CreateUpdateVariableModal } from './create-update-variable-modal' +import { renderWithProviders, screen, waitFor } from '@qovery/shared/util-tests' +import { CreateUpdateVariableModal, VariableFormModal } from './create-update-variable-modal' jest.mock('@qovery/shared/ui', () => { const actual = jest.requireActual('@qovery/shared/ui') @@ -97,6 +97,89 @@ describe('CreateUpdateVariableModal', () => { expect(descriptionField).not.toBeInstanceOf(HTMLTextAreaElement) }) + it('should render secret creation without the secret toggle and with hidden value control', () => { + renderWithProviders() + + expect(screen.getByText('New secret')).toBeInTheDocument() + expect(screen.queryByText('Secret variable')).not.toBeInTheDocument() + expect(screen.getByRole('checkbox', { name: /show value/i })).toBeInTheDocument() + }) + + it('should default to value format and show file inputs when selecting as file', async () => { + const { userEvent } = renderWithProviders() + + expect(screen.getByRole('radio', { name: /value/i })).toHaveAttribute('data-state', 'on') + expect(screen.queryByLabelText('Path')).not.toBeInTheDocument() + + await userEvent.click(screen.getByRole('radio', { name: /as file/i })) + + const pathField = screen.getByLabelText('Path') + + expect(screen.getByText('New variable file')).toBeInTheDocument() + expect(pathField).not.toHaveAttribute('placeholder') + expect(pathField).toHaveValue('') + expect(screen.getByText('Variable interpolation')).toBeInTheDocument() + + await userEvent.click(pathField) + + expect(pathField).toHaveValue('/') + }) + + it('should render secret file edit with hidden value control', async () => { + const { userEvent } = renderWithProviders( + + ) + + const valueField = screen.getByLabelText('Value') + + expect(screen.getByText('Edit secret file')).toBeInTheDocument() + expect(screen.getByRole('checkbox', { name: /show value/i })).toBeInTheDocument() + expect(valueField).toHaveClass('[-webkit-text-security:disc]') + + await userEvent.click(screen.getByRole('checkbox', { name: /show value/i })) + + expect(valueField).not.toHaveClass('[-webkit-text-security:disc]') + }) + + it('should submit a secret created as a file with the selected format', async () => { + const onSubmit = jest.fn() + const { userEvent } = renderWithProviders( + + ) + + await userEvent.click(screen.getByRole('radio', { name: /as file/i })) + await userEvent.type(screen.getByLabelText('Variable'), 'MY_SECRET') + await userEvent.click(screen.getByLabelText('Path')) + await userEvent.type(screen.getByLabelText('Path'), 'vault/secrets/my-secret') + await userEvent.type(screen.getByLabelText('Value'), 'secret-value') + await userEvent.click(screen.getByRole('button', { name: /confirm/i })) + + await waitFor(() => { + expect(onSubmit).toHaveBeenCalledWith( + expect.objectContaining({ + key: 'MY_SECRET', + value: 'secret-value', + scope: 'ENVIRONMENT', + isSecret: true, + isFile: true, + mountPath: '/vault/secrets/my-secret', + }) + ) + }) + }) + it('should not render the open editor button for aliases', () => { renderWithProviders() diff --git a/libs/domains/variables/feature/src/lib/create-update-variable-modal/create-update-variable-modal.tsx b/libs/domains/variables/feature/src/lib/create-update-variable-modal/create-update-variable-modal.tsx index f9f335a1ab5..e98889bc127 100644 --- a/libs/domains/variables/feature/src/lib/create-update-variable-modal/create-update-variable-modal.tsx +++ b/libs/domains/variables/feature/src/lib/create-update-variable-modal/create-update-variable-modal.tsx @@ -1,16 +1,18 @@ import { type APIVariableScopeEnum, type APIVariableTypeEnum, type VariableResponse } from 'qovery-typescript-axios' -import { useRef, useState } from 'react' +import { useId, useRef, useState } from 'react' import { Controller, FormProvider, useForm } from 'react-hook-form' import { match } from 'ts-pattern' import { Button, Callout, + Checkbox, Icon, InputSelect, InputText, InputTextArea, InputToggle, ModalCrud, + SegmentedControl, Tooltip, useModal, } from '@qovery/shared/ui' @@ -87,7 +89,7 @@ export type VariableFormModalProps = { variable?: VariableResponse mode: 'CREATE' | 'UPDATE' type: keyof typeof APIVariableTypeEnum - isFile?: boolean + isSecret?: boolean hasClusterSecretManagerConfigured?: boolean scope: Scope projectId?: string @@ -104,12 +106,18 @@ export function VariableFormModal(props: VariableFormModalProps) { variable, mode, type, - isFile, + isSecret, hasClusterSecretManagerConfigured = false, } = props - const _isFile = (variable && environmentVariableFile(variable)) || (isFile ?? false) + const isCreateValue = mode === 'CREATE' && type === 'VALUE' + const [isFileVariable, setIsFileVariable] = useState(() => + Boolean(type === 'FILE' || (variable && environmentVariableFile(variable))) + ) const { enableAlertClickOutside } = useModal() const [isValueEditorOpen, setIsValueEditorOpen] = useState(false) + const [showSecretValue, setShowSecretValue] = useState(false) + const showSecretValueId = useId() + const isSecretVariable = isCreateValue ? Boolean(isSecret) : Boolean(variable?.is_secret) const textareaRef = useRef(null) @@ -140,7 +148,7 @@ export function VariableFormModal(props: VariableFormModalProps) { const defaultScope = // Check if it's a file and the scope is one of services and assign the default scope to 'ENVIRONMENT' - isFile && ['APPLICATION', 'CONTAINER', 'JOB', 'HELM'].includes(scope) + isFileVariable && ['APPLICATION', 'CONTAINER', 'JOB', 'HELM'].includes(scope) ? 'ENVIRONMENT' : variable?.scope === 'BUILT_IN' ? undefined @@ -165,19 +173,36 @@ export function VariableFormModal(props: VariableFormModalProps) { key: variable?.key, scope: defaultScope, value: variable?.value, - isSecret: variable?.is_secret, + isSecret: isSecretVariable, description: variable?.description, - enable_interpolation_in_file: _isFile ? variable?.enable_interpolation_in_file ?? true : undefined, + enable_interpolation_in_file: isFileVariable ? variable?.enable_interpolation_in_file ?? true : undefined, mountPath, }, mode: 'onChange', }) + const handleVariableFormatChange = (isFileSelected: boolean) => { + setIsFileVariable(isFileSelected) + + if (!isFileSelected) { + return + } + + if (methods.getValues('enable_interpolation_in_file') === undefined) { + methods.setValue('enable_interpolation_in_file', true) + } + + const currentScope = methods.getValues('scope') + + if (currentScope && ['APPLICATION', 'CONTAINER', 'JOB', 'HELM'].includes(currentScope)) { + methods.setValue('scope', 'ENVIRONMENT', { shouldValidate: true, shouldDirty: true }) + } + } + methods.watch(() => enableAlertClickOutside(methods.formState.isDirty)) const watchScope = methods.watch('scope') - const watchIsSecret = methods.watch('isSecret') const watchMountPath = methods.watch('mountPath') - const valueEditorLanguage = getValueEditorLanguage({ isFile: _isFile, mountPath: watchMountPath }) + const valueEditorLanguage = getValueEditorLanguage({ isFile: isFileVariable, mountPath: watchMountPath }) const valueEditorServiceId = isValueEditorScope(watchScope) ? props.serviceId : undefined const valueEditorScope = isValueEditorScope(watchScope) ? watchScope : undefined @@ -187,14 +212,17 @@ export function VariableFormModal(props: VariableFormModalProps) { // allow empty variable value if (!cloneData.value) cloneData.value = '' - if (!_isFile) { + if (!isFileVariable) { delete cloneData.mountPath + delete cloneData.enable_interpolation_in_file + } else if (cloneData.enable_interpolation_in_file === undefined) { + cloneData.enable_interpolation_in_file = true } try { await onSubmit({ ...cloneData, - isFile: _isFile, + isFile: isFileVariable, }) closeModal() } catch (e) { @@ -211,19 +239,28 @@ export function VariableFormModal(props: VariableFormModalProps) { title = 'Create ' + (type === 'ALIAS' ? 'alias' : type === 'OVERRIDE' ? 'override' : '') } - title += ' variable' + (_isFile ? ' file' : '') + if (isCreateValue) { + title += isSecretVariable ? ' secret' : ' variable' + } else if (mode === 'UPDATE' && type !== 'ALIAS' && type !== 'OVERRIDE') { + title += isSecretVariable ? ' secret' : ' variable' + } else { + title += ' variable' + } + + title += isFileVariable ? ' file' : '' - const description = match({ type, _isFile }) + const description = match({ type, isFileVariable }) .with({ type: 'ALIAS' }, () => 'Aliases allow you to specify a different name for a variable on a specific scope.') .with({ type: 'OVERRIDE' }, () => 'Overrides allow you to define a different env var value on a specific scope.') .with( - { _isFile: true }, + { isFileVariable: true }, () => 'The content of the Value field will be mounted as a file in the specified "Path". Accessing the environment variable at runtime will return the "Path" of the file.' ) - .otherwise( - () => - "Environment variables can be accessed at both build and run time. Set them as ARGS in your Dockerfile to use them during build processes. At runtime, they're available to your application automatically. Secrets value can only be accessed by your application." + .otherwise(() => + isSecretVariable + ? 'Secrets can only be accessed by your application at runtime. Use them for sensitive values such as tokens, credentials, and private configuration.' + : "Environment variables can be accessed at both build and run time. Set them as ARGS in your Dockerfile to use them during build processes. At runtime, they're available to your application automatically." ) return ( @@ -236,6 +273,18 @@ export function VariableFormModal(props: VariableFormModalProps) { onSubmit={_onSubmit} loading={loading} > + {isCreateValue && ( + handleVariableFormatChange(value === 'FILE')} + > + Value + As file + + )} + {type === 'ALIAS' || type === 'OVERRIDE' ? ( ) : ( @@ -258,7 +307,7 @@ export function VariableFormModal(props: VariableFormModalProps) { /> )} - {_isFile && + {isFileVariable && (type === 'ALIAS' || type === 'OVERRIDE' || mode === 'UPDATE' ? ( ) : ( @@ -269,14 +318,22 @@ export function VariableFormModal(props: VariableFormModalProps) { required: 'Please enter a mount path.', }} render={({ field, fieldState: { error } }) => ( - +
{ + if (!field.value) { + field.onChange('/') + } + }} + > + +
)} /> ))} @@ -342,12 +399,13 @@ export function VariableFormModal(props: VariableFormModalProps) {
{props.environmentId && ( )}
+ {isSecretVariable && ( +
+ setShowSecretValue(checked === true)} + /> + +
+ )} )} - {_isFile && ( + {isFileVariable && ( - {mode === 'CREATE' && type === 'VALUE' && ( -
- ( - - )} - /> -
- )} - - {mode === 'CREATE' && type === 'VALUE' && watchIsSecret && hasClusterSecretManagerConfigured && ( + {isCreateValue && isSecretVariable && hasClusterSecretManagerConfigured && ( @@ -506,7 +559,7 @@ export type CreateUpdateVariableModalProps = { variable?: VariableResponse mode: 'CREATE' | 'UPDATE' type: keyof typeof APIVariableTypeEnum - isFile?: boolean + isSecret?: boolean hasClusterSecretManagerConfigured?: boolean } & CreateUpdateVariableModalScopeProps diff --git a/libs/domains/variables/feature/src/lib/flow-create-variable/flow-create-variable.tsx b/libs/domains/variables/feature/src/lib/flow-create-variable/flow-create-variable.tsx index 2d9d43be2e6..32224dd3d5d 100644 --- a/libs/domains/variables/feature/src/lib/flow-create-variable/flow-create-variable.tsx +++ b/libs/domains/variables/feature/src/lib/flow-create-variable/flow-create-variable.tsx @@ -10,7 +10,7 @@ import VariableRow from './variable-row/variable-row' export interface FlowCreateVariableProps { onBack: () => void onSubmit: FormEventHandler - onAdd: () => void + onAdd: (isSecret?: boolean) => void onRemove: (index: number) => void variables: VariableData[] availableScopes: APIVariableScopeEnum[] @@ -27,16 +27,22 @@ export function FlowCreateVariable({ availableScopes, }: FlowCreateVariableProps) { const { formState } = useFormContext() - const gridTemplateColumns = '1fr 1fr 1fr 56px 32px' + const gridTemplateColumns = '1fr 1fr 1fr 32px' return (
Environment variables - +
+ + +
@@ -71,7 +77,6 @@ export function FlowCreateVariable({ Variable Value Scope - Secret
)} diff --git a/libs/domains/variables/feature/src/lib/flow-create-variable/variable-row/variable-row.tsx b/libs/domains/variables/feature/src/lib/flow-create-variable/variable-row/variable-row.tsx index 062aa5f0b8e..142ac84c9a0 100644 --- a/libs/domains/variables/feature/src/lib/flow-create-variable/variable-row/variable-row.tsx +++ b/libs/domains/variables/feature/src/lib/flow-create-variable/variable-row/variable-row.tsx @@ -3,7 +3,7 @@ import { type APIVariableScopeEnum } from 'qovery-typescript-axios' import { useState } from 'react' import { Controller, useFormContext } from 'react-hook-form' import { type FlowVariableData } from '@qovery/shared/interfaces' -import { BlockContent, Button, Icon, InputSelectSmall, InputTextSmall, InputToggle, Tooltip } from '@qovery/shared/ui' +import { BlockContent, Button, Icon, InputSelectSmall, InputTextSmall, Tooltip } from '@qovery/shared/ui' import { generateScopeLabel } from '@qovery/shared/util-js' import { CodeEditorVariable } from '../../code-editor-variable/code-editor-variable' import { FieldVariableSuggestion } from '../../field-variable-suggestion/field-variable-suggestion' @@ -142,14 +142,6 @@ export function VariableRow(props: VariableRowProps) { )} /> -
- } - /> -
-
- - - _onCreateStandaloneVariable()} icon={}> - Variable - - _onCreateStandaloneVariable(true)} - icon={} - > - Variable as file - - - + -
diff --git a/libs/domains/variables/feature/src/lib/variables-action-toolbar/variables-action-toolbar.tsx b/libs/domains/variables/feature/src/lib/variables-action-toolbar/variables-action-toolbar.tsx index 4ab6dfd583a..d2dc95baee9 100644 --- a/libs/domains/variables/feature/src/lib/variables-action-toolbar/variables-action-toolbar.tsx +++ b/libs/domains/variables/feature/src/lib/variables-action-toolbar/variables-action-toolbar.tsx @@ -40,7 +40,7 @@ export function VariablesActionToolbar({ const hasImportEnvFile = Boolean(onImportEnvFile) const showImportButton = hasImportEnvFile && importEnvFileAccess === 'button' - const _onCreateVariable = (isFile?: boolean) => + const _onCreateVariable = (isSecret = false) => openModal({ content: ( @@ -106,25 +106,15 @@ export function VariablesActionToolbar({ - - - - - - _onCreateVariable()} icon={}> - Variable - - _onCreateVariable(true)} - icon={} - > - Variable as file - - - + + +
) } diff --git a/libs/shared/ui/src/lib/components/inputs/input-text-area/input-text-area.tsx b/libs/shared/ui/src/lib/components/inputs/input-text-area/input-text-area.tsx index e292acac426..1173ae8805e 100644 --- a/libs/shared/ui/src/lib/components/inputs/input-text-area/input-text-area.tsx +++ b/libs/shared/ui/src/lib/components/inputs/input-text-area/input-text-area.tsx @@ -11,10 +11,11 @@ export interface InputTextAreaProps { hint?: ReactNode error?: string dataTestId?: string + maskValue?: boolean } export const InputTextArea = forwardRef(function InputTextArea(props, ref) { - const { label, value = '', name, onChange, className, hint, error, dataTestId = 'input-textarea' } = props + const { label, value = '', name, onChange, className, hint, error, dataTestId = 'input-textarea', maskValue } = props const [currentValue, setCurrentValue] = useState(value) const previousValueRef = useRef(value) @@ -71,7 +72,10 @@ export const InputTextArea = forwardRef ref={ref} name={name} id={label} - className="mt-5 min-h-[52px] w-full appearance-none bg-transparent pr-3 text-sm text-neutral outline-0" + className={twMerge( + 'mt-5 min-h-[52px] w-full appearance-none bg-transparent pr-3 text-sm text-neutral outline-0', + maskValue && '[-webkit-text-security:disc]' + )} value={!currentValue ? undefined : currentValue} onChange={(e) => { if (onChange) onChange(e) From 0405222296c3f427f18fb573f92c6ec732834bd4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9mi=20Bonnet?= Date: Fri, 31 Jul 2026 16:24:34 +0200 Subject: [PATCH 5/5] fix(services): support agentic workflows in the service list (#2843) * fix(services): isolate agentic workflows from service list * chore(services): remove unrelated api changes * fix(services): preserve editable service fallback --- .../$environmentId/overview/route.tsx | 7 +- .../src/lib/domains-services-data-access.ts | 12 ++- libs/domains/services/feature/src/index.ts | 2 + .../agentic-workflow-service-list.spec.tsx | 33 ++++++++ .../agentic-workflow-service-list.tsx | 80 +++++++++++++++++++ .../use-agentic-workflow-services.ts | 16 ++++ .../lib/service-list/service-list.spec.tsx | 18 +++++ 7 files changed, 166 insertions(+), 2 deletions(-) create mode 100644 libs/domains/services/feature/src/lib/agentic-workflow-service-list/agentic-workflow-service-list.spec.tsx create mode 100644 libs/domains/services/feature/src/lib/agentic-workflow-service-list/agentic-workflow-service-list.tsx create mode 100644 libs/domains/services/feature/src/lib/hooks/use-agentic-workflow-services/use-agentic-workflow-services.ts diff --git a/apps/console/src/routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/overview/route.tsx b/apps/console/src/routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/overview/route.tsx index d99c028b8b6..57a2a628f12 100644 --- a/apps/console/src/routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/overview/route.tsx +++ b/apps/console/src/routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/overview/route.tsx @@ -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, @@ -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( @@ -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, @@ -195,6 +197,9 @@ function RouteComponent() { )}
{shouldDisplayArgoCdServicesBelowQovery && } + {isServicesListTab && isAgenticWorkflowEnabled && ( + + )} diff --git a/libs/domains/services/data-access/src/lib/domains-services-data-access.ts b/libs/domains/services/data-access/src/lib/domains-services-data-access.ts index de59538e01e..f35a54b415a 100644 --- a/libs/domains/services/data-access/src/lib/domains-services-data-access.ts +++ b/libs/domains/services/data-access/src/lib/domains-services-data-access.ts @@ -1,5 +1,7 @@ import { createQueryKeys, type inferQueryKeys } from '@lukemorales/query-key-factory' import { + type AgenticWorkflowResponse, + AgenticWorkflowsApi, ApplicationActionsApi, type ApplicationAdvancedSettings, ApplicationConfigurationApi, @@ -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() @@ -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 { @@ -365,6 +368,13 @@ export const services = createQueryKeys('services', { ) }, }), + listAgenticWorkflows: (environmentId: string) => ({ + queryKey: [environmentId], + async queryFn(): Promise { + const response = await agenticWorkflowsApi.listAgenticWorkflows(environmentId) + return response.data.results ?? [] + }, + }), argocdManifest: (serviceId: string) => ({ queryKey: [serviceId], async queryFn() { diff --git a/libs/domains/services/feature/src/index.ts b/libs/domains/services/feature/src/index.ts index f798adcc252..744892f77ba 100644 --- a/libs/domains/services/feature/src/index.ts +++ b/libs/domains/services/feature/src/index.ts @@ -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' diff --git a/libs/domains/services/feature/src/lib/agentic-workflow-service-list/agentic-workflow-service-list.spec.tsx b/libs/domains/services/feature/src/lib/agentic-workflow-service-list/agentic-workflow-service-list.spec.tsx new file mode 100644 index 00000000000..3d2bf230e10 --- /dev/null +++ b/libs/domains/services/feature/src/lib/agentic-workflow-service-list/agentic-workflow-service-list.spec.tsx @@ -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() + + 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() + + 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() + }) +}) diff --git a/libs/domains/services/feature/src/lib/agentic-workflow-service-list/agentic-workflow-service-list.tsx b/libs/domains/services/feature/src/lib/agentic-workflow-service-list/agentic-workflow-service-list.tsx new file mode 100644 index 00000000000..5277d0b36fa --- /dev/null +++ b/libs/domains/services/feature/src/lib/agentic-workflow-service-list/agentic-workflow-service-list.tsx @@ -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 ( +
+
+ + Agentic workflows + +

AI workflows triggered through webhooks and connected services.

+
+ +
+
+ + {enabledServicesCount} enabled + +
+ + + + + Service + + + Model + + + Status + + + + + {services.map((service: AgenticWorkflowResponse) => ( + + +
+ + + {service.name} + +
+
+ + {service.model?.type ?? '-'} + + + + {service.enabled ? 'Enabled' : 'Disabled'} + + +
+ ))} +
+
+
+
+ ) +} + +export default AgenticWorkflowServiceList diff --git a/libs/domains/services/feature/src/lib/hooks/use-agentic-workflow-services/use-agentic-workflow-services.ts b/libs/domains/services/feature/src/lib/hooks/use-agentic-workflow-services/use-agentic-workflow-services.ts new file mode 100644 index 00000000000..f712ad971e8 --- /dev/null +++ b/libs/domains/services/feature/src/lib/hooks/use-agentic-workflow-services/use-agentic-workflow-services.ts @@ -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 diff --git a/libs/domains/services/feature/src/lib/service-list/service-list.spec.tsx b/libs/domains/services/feature/src/lib/service-list/service-list.spec.tsx index 58e52241fe8..f67bda65b57 100644 --- a/libs/domains/services/feature/src/lib/service-list/service-list.spec.tsx +++ b/libs/domains/services/feature/src/lib/service-list/service-list.spec.tsx @@ -469,6 +469,24 @@ describe('ServiceList', () => { expect(screen.queryByText('ARGOCD APP')).not.toBeInTheDocument() }) + it('should not pass agentic workflows to the standard service table', () => { + mockServicesData = [ + { + id: 'agentic-workflow-id', + name: 'Review pull requests', + service_type: 'AGENTIC_WORKFLOW', + serviceType: 'AGENTIC_WORKFLOW', + enabled: true, + model: { type: 'CLAUDE' }, + }, + ] + + renderWithProviders() + + expect(screen.getByText('No service found')).toBeInTheDocument() + expect(screen.queryByText('Review pull requests')).not.toBeInTheDocument() + }) + it('should navigate to service on row click', async () => { const { userEvent } = renderWithProviders() const rows = screen.getAllByRole('row')