diff --git a/apps/console/src/routeTree.gen.ts b/apps/console/src/routeTree.gen.ts index 866abbfcc35..55b3852e0cd 100644 --- a/apps/console/src/routeTree.gen.ts +++ b/apps/console/src/routeTree.gen.ts @@ -163,6 +163,7 @@ import { Route as AuthenticatedOrganizationOrganizationIdProjectProjectIdEnviron import { Route as AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceCreateSlugGeneralRouteImport } from './routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/create/$slug/general' import { Route as AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdVariablesExternalSecretsRouteImport } from './routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/variables/external-secrets' import { Route as AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdVariablesBuiltInRouteImport } from './routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/variables/built-in' +import { Route as AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdUpdateBlueprintRouteImport } from './routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/update/blueprint' import { Route as AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdSettingsValuesOverrideFileRouteImport } from './routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/settings/values-override-file' import { Route as AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdSettingsValuesOverrideArgumentsRouteImport } from './routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/settings/values-override-arguments' import { Route as AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdSettingsTerraformVariablesRouteImport } from './routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/settings/terraform-variables' @@ -1444,6 +1445,14 @@ const AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironm AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdVariablesRouteRoute, } as any, ) +const AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdUpdateBlueprintRoute = + AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdUpdateBlueprintRouteImport.update( + { + id: '/project/$projectId/environment/$environmentId/service/$serviceId/update/blueprint', + path: '/project/$projectId/environment/$environmentId/service/$serviceId/update/blueprint', + getParentRoute: () => AuthenticatedOrganizationOrganizationIdRouteRoute, + } as any, + ) const AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdSettingsValuesOverrideFileRoute = AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdSettingsValuesOverrideFileRouteImport.update( { @@ -1827,6 +1836,7 @@ export interface FileRoutesByFullPath { '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/settings/terraform-variables': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdSettingsTerraformVariablesRoute '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/settings/values-override-arguments': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdSettingsValuesOverrideArgumentsRoute '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/settings/values-override-file': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdSettingsValuesOverrideFileRoute + '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/update/blueprint': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdUpdateBlueprintRoute '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/variables/built-in': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdVariablesBuiltInRoute '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/variables/external-secrets': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdVariablesExternalSecretsRoute '/organization/$organizationId/project/$projectId/environment/$environmentId/service/create/$slug/general': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceCreateSlugGeneralRoute @@ -1991,6 +2001,7 @@ export interface FileRoutesByTo { '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/settings/terraform-variables': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdSettingsTerraformVariablesRoute '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/settings/values-override-arguments': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdSettingsValuesOverrideArgumentsRoute '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/settings/values-override-file': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdSettingsValuesOverrideFileRoute + '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/update/blueprint': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdUpdateBlueprintRoute '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/variables/built-in': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdVariablesBuiltInRoute '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/variables/external-secrets': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdVariablesExternalSecretsRoute '/organization/$organizationId/project/$projectId/environment/$environmentId/service/create/$slug/general': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceCreateSlugGeneralRoute @@ -2175,6 +2186,7 @@ export interface FileRoutesById { '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/settings/terraform-variables': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdSettingsTerraformVariablesRoute '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/settings/values-override-arguments': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdSettingsValuesOverrideArgumentsRoute '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/settings/values-override-file': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdSettingsValuesOverrideFileRoute + '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/update/blueprint': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdUpdateBlueprintRoute '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/variables/built-in': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdVariablesBuiltInRoute '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/variables/external-secrets': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdVariablesExternalSecretsRoute '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/create/$slug/general': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceCreateSlugGeneralRoute @@ -2360,6 +2372,7 @@ export interface FileRouteTypes { | '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/settings/terraform-variables' | '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/settings/values-override-arguments' | '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/settings/values-override-file' + | '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/update/blueprint' | '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/variables/built-in' | '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/variables/external-secrets' | '/organization/$organizationId/project/$projectId/environment/$environmentId/service/create/$slug/general' @@ -2524,6 +2537,7 @@ export interface FileRouteTypes { | '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/settings/terraform-variables' | '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/settings/values-override-arguments' | '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/settings/values-override-file' + | '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/update/blueprint' | '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/variables/built-in' | '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/variables/external-secrets' | '/organization/$organizationId/project/$projectId/environment/$environmentId/service/create/$slug/general' @@ -2707,6 +2721,7 @@ export interface FileRouteTypes { | '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/settings/terraform-variables' | '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/settings/values-override-arguments' | '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/settings/values-override-file' + | '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/update/blueprint' | '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/variables/built-in' | '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/variables/external-secrets' | '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/create/$slug/general' @@ -3846,6 +3861,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdVariablesBuiltInRouteImport parentRoute: typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdVariablesRouteRoute } + '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/update/blueprint': { + id: '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/update/blueprint' + path: '/project/$projectId/environment/$environmentId/service/$serviceId/update/blueprint' + fullPath: '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/update/blueprint' + preLoaderRoute: typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdUpdateBlueprintRouteImport + parentRoute: typeof AuthenticatedOrganizationOrganizationIdRouteRoute + } '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/settings/values-override-file': { id: '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/settings/values-override-file' path: '/values-override-file' @@ -4681,6 +4703,7 @@ interface AuthenticatedOrganizationOrganizationIdRouteRouteChildren { AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdDeploymentDeploymentIdIndexRoute: typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdDeploymentDeploymentIdIndexRoute AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdIndexRoute: typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdIndexRoute AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceCreateIndexRoute: typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceCreateIndexRoute + AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdUpdateBlueprintRoute: typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdUpdateBlueprintRoute AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdDeploymentsIndexRoute: typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdDeploymentsIndexRoute AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceCreateBlueprintProviderServiceFamilyRouteRoute: typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceCreateBlueprintProviderServiceFamilyRouteRouteWithChildren AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdDeploymentsLogsExecutionIdRoute: typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdDeploymentsLogsExecutionIdRoute @@ -4776,6 +4799,8 @@ const AuthenticatedOrganizationOrganizationIdRouteRouteChildren: AuthenticatedOr AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdIndexRoute, AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceCreateIndexRoute: AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceCreateIndexRoute, + AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdUpdateBlueprintRoute: + AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdUpdateBlueprintRoute, AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdDeploymentsIndexRoute: AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdDeploymentsIndexRoute, AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceCreateBlueprintProviderServiceFamilyRouteRoute: diff --git a/apps/console/src/routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/update/blueprint.tsx b/apps/console/src/routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/update/blueprint.tsx new file mode 100644 index 00000000000..3cac902e4e0 --- /dev/null +++ b/apps/console/src/routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/update/blueprint.tsx @@ -0,0 +1,41 @@ +import { createFileRoute, useNavigate } from '@tanstack/react-router' +import { useCallback, useEffect } from 'react' +import { useEnvironment } from '@qovery/domains/environments/feature' +import { BlueprintUpdateFlow, useService } from '@qovery/domains/services/feature' + +export const Route = createFileRoute( + '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/update/blueprint' +)({ + component: RouteComponent, +}) + +function RouteComponent() { + const { organizationId, projectId, environmentId, serviceId } = Route.useParams() + const navigate = useNavigate() + const { data: environment } = useEnvironment({ environmentId, suspense: true }) + const { data: service } = useService({ environmentId, serviceId, suspense: true }) + const blueprintId = service && 'blueprint_id' in service ? service.blueprint_id : undefined + const navigateToOverview = useCallback(() => { + navigate({ + to: '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/overview', + params: { organizationId, projectId, environmentId, serviceId }, + }) + }, [environmentId, navigate, organizationId, projectId, serviceId]) + + useEffect(() => { + if (service && !blueprintId) navigateToOverview() + }, [blueprintId, navigateToOverview, service]) + + if (!service || !blueprintId) return null + + return ( + + ) +} diff --git a/apps/console/src/routes/_authenticated/organization/$organizationId/route.tsx b/apps/console/src/routes/_authenticated/organization/$organizationId/route.tsx index a32b43ec801..79cc12afcf5 100644 --- a/apps/console/src/routes/_authenticated/organization/$organizationId/route.tsx +++ b/apps/console/src/routes/_authenticated/organization/$organizationId/route.tsx @@ -56,6 +56,7 @@ const hiddenProgressCardRouteIds: FileRouteTypes['id'][] = [ '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/create/helm', '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/create/lifecycle-job', '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/create/terraform', + '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/update/blueprint', ] function RouteComponent() { diff --git a/apps/console/src/routes/_authenticated/organization/route.tsx b/apps/console/src/routes/_authenticated/organization/route.tsx index 584d56f5445..dbb2c1271a2 100644 --- a/apps/console/src/routes/_authenticated/organization/route.tsx +++ b/apps/console/src/routes/_authenticated/organization/route.tsx @@ -493,6 +493,7 @@ const bypassLayoutRouteIds: FileRouteTypes['id'][] = [ '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/create/terraform', '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/create/lifecycle-job', '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/create/cron-job', + '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/update/blueprint', ] function useBypassLayout(): boolean { 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 2e48a837e0d..835ee0ff981 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 @@ -16,6 +16,7 @@ import { BlueprintCatalogApi, type BlueprintCreateRequest, BlueprintMainCallsApi, + type BlueprintUpdateRequest, type CleanFailedJobsRequest, ContainerActionsApi, type ContainerAdvancedSettings, @@ -769,6 +770,16 @@ type CreateBlueprintRequest = { deploy?: boolean } +type PreviewBlueprintUpdateRequest = { + blueprintId: string + payload: BlueprintUpdateRequest +} + +type UpdateBlueprintRequest = { + blueprintId: string + payload: BlueprintUpdateRequest +} + type EditServiceRequest = { serviceId: string payload: @@ -1042,6 +1053,14 @@ export const mutations = { const response = await blueprintApi.createBlueprint(environmentId, payload, deploy) return response.data }, + async previewBlueprintUpdate({ blueprintId, payload }: PreviewBlueprintUpdateRequest) { + const response = await blueprintApi.previewBlueprintUpdate(blueprintId, payload) + return response.data + }, + async updateBlueprint({ blueprintId, payload }: UpdateBlueprintRequest) { + const response = await blueprintApi.updateBlueprint(blueprintId, payload) + return response.data + }, async editService({ serviceId, payload }: EditServiceRequest) { const { mutation } = match(payload) .with({ serviceType: 'APPLICATION' }, ({ serviceType, ...payload }) => ({ diff --git a/libs/domains/services/feature/src/index.ts b/libs/domains/services/feature/src/index.ts index 5bdab058317..c461f4b9f7e 100644 --- a/libs/domains/services/feature/src/index.ts +++ b/libs/domains/services/feature/src/index.ts @@ -34,6 +34,9 @@ export * from './lib/hooks/use-blueprint-catalog/use-blueprint-catalog' export * from './lib/hooks/use-blueprint-catalog-service-readme/use-blueprint-catalog-service-readme' export * from './lib/hooks/use-blueprint-catalog-service-manifest/use-blueprint-catalog-service-manifest' export * from './lib/hooks/use-blueprint-update/use-blueprint-update' +export * from './lib/hooks/use-blueprint-update-preview-socket/use-blueprint-update-preview-socket' +export * from './lib/hooks/use-preview-blueprint-update/use-preview-blueprint-update' +export * from './lib/hooks/use-update-blueprint/use-update-blueprint' export * from './lib/hooks/use-blueprint-service-created-socket/use-blueprint-service-created-socket' export * from './lib/blueprint-query-boundary/blueprint-query-boundary' export * from './lib/hooks/use-service-deployment-and-running-statuses/use-service-deployment-and-running-statuses' @@ -102,6 +105,7 @@ export * from './lib/service-creation-flow/database/step-resources/step-resource export * from './lib/service-creation-flow/database/database-summary-view/database-summary-view' export * from './lib/service-creation-flow/database/step-summary/step-summary' export * from './lib/service-creation-flow/blueprint/blueprint-creation-flow' +export * from './lib/service-update-flow/blueprint/blueprint-update-flow' export * from './lib/application-container-healthchecks/application-container-healthchecks-form/application-container-healthchecks-form' export * from './lib/application-container-healthchecks/healthchecks-utils' export * from './lib/application-container-healthchecks/step-healthchecks/step-healthchecks' diff --git a/libs/domains/services/feature/src/lib/blueprint-field-utils/blueprint-field-utils.ts b/libs/domains/services/feature/src/lib/blueprint-field-utils/blueprint-field-utils.ts new file mode 100644 index 00000000000..761bda23046 --- /dev/null +++ b/libs/domains/services/feature/src/lib/blueprint-field-utils/blueprint-field-utils.ts @@ -0,0 +1,135 @@ +import { + type BlueprintManifestContextVariableField, + type BlueprintManifestResponseResultsInner, + type BlueprintManifestVariableField, +} from 'qovery-typescript-axios' + +export type BlueprintFieldValue = string | boolean +export type BlueprintFieldValues = Record +export type BlueprintFieldPath = `fields.${string}` + +export type OverridableBlueprintManifestContextVariableField = BlueprintManifestContextVariableField & { + overridable?: boolean +} + +export function getBlueprintFieldPath(name: string): BlueprintFieldPath { + return `fields.${name}` +} + +export function formatFieldLabel(name: string) { + const label = name.replace(/_/g, ' ') + return `${label.charAt(0).toUpperCase()}${label.slice(1)}` +} + +export function getDefaultFieldValue(field: BlueprintManifestVariableField): BlueprintFieldValue { + if (field.type.type === 'bool') return field.default_value === 'true' + return field.default_value ?? '' +} + +export function getDefaultContextFieldValue(field: BlueprintManifestContextVariableField): BlueprintFieldValue { + return field.value ?? '' +} + +export function getDefaultBlueprintFieldValues(blueprintManifestFields: BlueprintManifestResponseResultsInner[]) { + const requiredBlueprintFields = blueprintManifestFields.filter(isRequiredVariableField) + const optionalBlueprintFields = blueprintManifestFields.filter(isOptionalVariableField) + const overridableContextBlueprintFields = blueprintManifestFields.filter(isOverridableContextVariableField) + const fieldsWithDefaultValue = [...requiredBlueprintFields, ...optionalBlueprintFields] + + return { + ...Object.fromEntries(fieldsWithDefaultValue.map((field) => [field.name, getDefaultFieldValue(field)])), + ...Object.fromEntries( + overridableContextBlueprintFields.map((field) => [field.name, getDefaultContextFieldValue(field)]) + ), + } +} + +export function getStringFieldValue(value: BlueprintFieldValue | undefined) { + return typeof value === 'string' ? value : '' +} + +export function getBooleanFieldValue(value: BlueprintFieldValue | undefined) { + return typeof value === 'boolean' ? value : false +} + +export function isFieldValueFulfilled(value: BlueprintFieldValue | undefined) { + if (typeof value === 'boolean') return true + return Boolean(value?.trim()) +} + +export function isFieldValueMatchingPattern( + field: BlueprintManifestVariableField, + value: BlueprintFieldValue | undefined +) { + if (typeof value !== 'string' || !value || !field.type.pattern) return true + + try { + return new RegExp(field.type.pattern).test(value) + } catch { + return true + } +} + +export function getFieldLengthValidationError( + field: BlueprintManifestVariableField, + value: BlueprintFieldValue | undefined +) { + if (typeof value !== 'string' || !value) return undefined + + const { min_length: minLength, max_length: maxLength } = field.type + const hasMinLength = typeof minLength === 'number' + const hasMaxLength = typeof maxLength === 'number' + + if (hasMinLength && hasMaxLength && (value.length < minLength || value.length > maxLength)) { + return `Value must be between ${minLength} and ${maxLength} characters.` + } + + if (hasMinLength && value.length < minLength) return `Value must be at least ${minLength} characters.` + if (hasMaxLength && value.length > maxLength) return `Value must be at most ${maxLength} characters.` + + return undefined +} + +export function getFieldValidationError(field: BlueprintManifestVariableField, value: BlueprintFieldValue | undefined) { + const lengthValidationError = getFieldLengthValidationError(field, value) + if (lengthValidationError) return lengthValidationError + + if (!isFieldValueMatchingPattern(field, value)) return 'Value does not match the expected format.' + return undefined +} + +export function isFieldValid(field: BlueprintManifestVariableField, value: BlueprintFieldValue | undefined) { + if (field.required && !isFieldValueFulfilled(value)) return false + return !getFieldValidationError(field, value) +} + +export function isVariableField(field: BlueprintManifestResponseResultsInner): field is BlueprintManifestVariableField { + return field.kind === 'variable' +} + +export function isRequiredVariableField( + field: BlueprintManifestResponseResultsInner +): field is BlueprintManifestVariableField { + return isVariableField(field) && field.required +} + +export function isOptionalVariableField( + field: BlueprintManifestResponseResultsInner +): field is BlueprintManifestVariableField { + return isVariableField(field) && !field.required +} + +export function isOverridableContextVariableField( + field: BlueprintManifestResponseResultsInner +): field is OverridableBlueprintManifestContextVariableField { + return field.kind === 'contextVariable' && 'overridable' in field && field.overridable === true +} + +export function getSummaryFieldValue( + field: BlueprintManifestVariableField | OverridableBlueprintManifestContextVariableField, + value: BlueprintFieldValue | undefined +) { + if (typeof value === 'boolean') return value ? 'Enabled' : 'Disabled' + if (field.kind === 'variable' && field.is_secret && value) return '••••••••' + return value +} diff --git a/libs/domains/services/feature/src/lib/service-creation-flow/blueprint/blueprint-configuration-view/blueprint-creation-components/blueprint-manifest-variable-input/blueprint-manifest-variable-input.tsx b/libs/domains/services/feature/src/lib/blueprint-manifest-variable-input/blueprint-manifest-variable-input.tsx similarity index 85% rename from libs/domains/services/feature/src/lib/service-creation-flow/blueprint/blueprint-configuration-view/blueprint-creation-components/blueprint-manifest-variable-input/blueprint-manifest-variable-input.tsx rename to libs/domains/services/feature/src/lib/blueprint-manifest-variable-input/blueprint-manifest-variable-input.tsx index 45710393463..64efab45748 100644 --- a/libs/domains/services/feature/src/lib/service-creation-flow/blueprint/blueprint-configuration-view/blueprint-creation-components/blueprint-manifest-variable-input/blueprint-manifest-variable-input.tsx +++ b/libs/domains/services/feature/src/lib/blueprint-manifest-variable-input/blueprint-manifest-variable-input.tsx @@ -5,13 +5,14 @@ import { formatFieldLabel, getBooleanFieldValue, getStringFieldValue, -} from '../../../blueprint-creation-utils/blueprint-creation-utils' -import { CheckboxField } from '../checkbox-field/checkbox-field' +} from '../blueprint-field-utils/blueprint-field-utils' +import { CheckboxField } from './checkbox-field' export interface BlueprintManifestVariableInputProps { autoFocus?: boolean error?: string field: BlueprintManifestVariableField + label?: string onChange: (value: BlueprintFieldValue) => void value: BlueprintFieldValue | undefined } @@ -20,15 +21,16 @@ export function BlueprintManifestVariableInput({ autoFocus, error, field, + label, onChange, value, }: BlueprintManifestVariableInputProps) { - const label = formatFieldLabel(field.name) + const inputLabel = label ?? formatFieldLabel(field.name) if (field.allowed_values?.length) { return ( ({ label: allowedValue, value: allowedValue }))} autoFocus={autoFocus} @@ -46,7 +48,7 @@ export function BlueprintManifestVariableInput({ autoFocus={autoFocus} checked={getBooleanFieldValue(value)} description={field.description ?? ''} - label={label} + label={inputLabel} name={field.name} onChange={onChange} /> @@ -56,7 +58,7 @@ export function BlueprintManifestVariableInput({ return ( ({ + QOVERY_WS: 'wss://ws.qovery.com', +})) + +jest.mock('@qovery/state/util-queries', () => ({ + useReactQueryWsSubscription: jest.fn(), +})) + +const useReactQueryWsSubscriptionMock = jest.mocked(useReactQueryWsSubscription) + +describe('useBlueprintUpdatePreviewSocket', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it('should subscribe to the blueprint preview endpoint with organization, cluster and preview id', () => { + renderHook(() => + useBlueprintUpdatePreviewSocket({ + organizationId: 'org-1', + clusterId: 'cluster-1', + previewId: 'preview-1', + }) + ) + + expect(useReactQueryWsSubscriptionMock).toHaveBeenCalledWith( + expect.objectContaining({ + url: 'wss://ws.qovery.com/blueprint/preview', + urlSearchParams: { + organization: 'org-1', + cluster: 'cluster-1', + preview_id: 'preview-1', + }, + enabled: true, + }) + ) + }) + + it('should not subscribe until all params are available', () => { + renderHook(() => + useBlueprintUpdatePreviewSocket({ + organizationId: 'org-1', + previewId: 'preview-1', + }) + ) + + expect(useReactQueryWsSubscriptionMock).toHaveBeenCalledWith(expect.objectContaining({ enabled: false })) + }) + + it('should expose deployment impact and raw output from socket messages', () => { + const { result } = renderHook(() => + useBlueprintUpdatePreviewSocket({ + organizationId: 'org-1', + clusterId: 'cluster-1', + previewId: 'preview-1', + }) + ) + const subscriptionConfig = useReactQueryWsSubscriptionMock.mock.calls[0]?.[0] + + act(() => { + subscriptionConfig?.onOpen?.({} as QueryClient, {} as Event) + expect(result.current.hasReceivedMessage).toBe(false) + subscriptionConfig?.onMessage?.({} as QueryClient, { + deployment_impact: { + severity: 'high', + description: [ + 'Upgrading engine_version forces a full recreation on AWS.', + 'Verify your database endpoint before confirming.', + ], + impacted_services: ['Postgres database', 'Redis cache'], + }, + raw_output: '# Terraform will perform the following actions:', + }) + }) + + expect(result.current.isLoading).toBe(false) + expect(result.current.hasReceivedMessage).toBe(true) + expect(result.current.impact).toEqual({ + level: 'high', + description: [ + 'Upgrading engine_version forces a full recreation on AWS.', + 'Verify your database endpoint before confirming.', + ], + impactedServices: ['Postgres database', 'Redis cache'], + }) + expect(result.current.rawOutput).toBe('# Terraform will perform the following actions:') + }) + + it('should append plain text output chunks', () => { + const { result } = renderHook(() => + useBlueprintUpdatePreviewSocket({ + organizationId: 'org-1', + clusterId: 'cluster-1', + previewId: 'preview-1', + }) + ) + const subscriptionConfig = useReactQueryWsSubscriptionMock.mock.calls[0]?.[0] + + act(() => { + subscriptionConfig?.onMessage?.({} as QueryClient, '# first line') + subscriptionConfig?.onMessage?.({} as QueryClient, '# second line') + }) + + expect(result.current.rawOutput).toBe('# first line\n# second line') + expect(result.current.hasReceivedMessage).toBe(true) + }) + + it('should use the payload property as raw output', () => { + const { result } = renderHook(() => + useBlueprintUpdatePreviewSocket({ + organizationId: 'org-1', + clusterId: 'cluster-1', + previewId: 'preview-1', + }) + ) + const subscriptionConfig = useReactQueryWsSubscriptionMock.mock.calls[0]?.[0] + + act(() => { + subscriptionConfig?.onMessage?.({} as QueryClient, { + type: 'diff', + payload: '\nTerraform will perform the following actions:\n\n # aws_db_instance.this will be created', + service_type: 'TERRAFORM', + }) + }) + + expect(result.current.rawOutput).toBe( + '\nTerraform will perform the following actions:\n\n # aws_db_instance.this will be created' + ) + expect(result.current.hasReceivedMessage).toBe(true) + }) + + it('should reset message state when preview id changes', () => { + const { result, rerender } = renderHook( + ({ previewId }) => + useBlueprintUpdatePreviewSocket({ + organizationId: 'org-1', + clusterId: 'cluster-1', + previewId, + }), + { initialProps: { previewId: 'preview-1' } } + ) + const subscriptionConfig = useReactQueryWsSubscriptionMock.mock.calls[0]?.[0] + + act(() => { + subscriptionConfig?.onMessage?.({} as QueryClient, '# output') + }) + + expect(result.current.hasReceivedMessage).toBe(true) + + rerender({ previewId: 'preview-2' }) + + expect(result.current.hasReceivedMessage).toBe(false) + expect(result.current.rawOutput).toBe('') + }) + + it('should keep websocket lifecycle handlers stable after state updates', () => { + renderHook(() => + useBlueprintUpdatePreviewSocket({ + organizationId: 'org-1', + clusterId: 'cluster-1', + previewId: 'preview-1', + }) + ) + const subscriptionConfig = useReactQueryWsSubscriptionMock.mock.calls[0]?.[0] + + act(() => { + subscriptionConfig?.onOpen?.({} as QueryClient, {} as Event) + }) + + const nextSubscriptionConfig = useReactQueryWsSubscriptionMock.mock.calls.at(-1)?.[0] + expect(nextSubscriptionConfig?.onOpen).toBe(subscriptionConfig?.onOpen) + expect(nextSubscriptionConfig?.onError).toBe(subscriptionConfig?.onError) + expect(nextSubscriptionConfig?.onClose).toBe(subscriptionConfig?.onClose) + expect(nextSubscriptionConfig?.onMessage).toBe(subscriptionConfig?.onMessage) + }) +}) diff --git a/libs/domains/services/feature/src/lib/hooks/use-blueprint-update-preview-socket/use-blueprint-update-preview-socket.ts b/libs/domains/services/feature/src/lib/hooks/use-blueprint-update-preview-socket/use-blueprint-update-preview-socket.ts new file mode 100644 index 00000000000..07d4b57d7ec --- /dev/null +++ b/libs/domains/services/feature/src/lib/hooks/use-blueprint-update-preview-socket/use-blueprint-update-preview-socket.ts @@ -0,0 +1,214 @@ +import { type QueryClient } from '@tanstack/react-query' +import { useCallback, useEffect, useState } from 'react' +import { QOVERY_WS } from '@qovery/shared/util-node-env' +import { useReactQueryWsSubscription } from '@qovery/state/util-queries' + +export type BlueprintUpdatePreviewImpactLevel = 'low' | 'medium' | 'high' | 'unknown' + +export interface BlueprintUpdatePreviewImpact { + level: BlueprintUpdatePreviewImpactLevel + description: string[] + impactedServices: string[] +} + +export interface BlueprintUpdatePreviewSocketData { + impact?: BlueprintUpdatePreviewImpact + rawOutput: string + isLoading: boolean + hasError: boolean + hasReceivedMessage: boolean +} + +export interface UseBlueprintUpdatePreviewSocketProps { + organizationId?: string + clusterId?: string + previewId?: string + enabled?: boolean +} + +type PreviewSocketObjectMessage = Record + +export function useBlueprintUpdatePreviewSocket({ + organizationId, + clusterId, + previewId, + enabled = true, +}: UseBlueprintUpdatePreviewSocketProps): BlueprintUpdatePreviewSocketData { + const [impact, setImpact] = useState() + const [rawOutput, setRawOutput] = useState('') + const [isLoading, setIsLoading] = useState(false) + const [hasError, setHasError] = useState(false) + const [hasReceivedMessage, setHasReceivedMessage] = useState(false) + + useEffect(() => { + setImpact(undefined) + setRawOutput('') + setIsLoading(false) + setHasError(false) + setHasReceivedMessage(false) + }, [clusterId, organizationId, previewId]) + + const appendRawOutput = useCallback((nextRawOutput: string) => { + setRawOutput((currentRawOutput) => { + if (!currentRawOutput) return nextRawOutput + return `${currentRawOutput}${nextRawOutput.startsWith('\n') ? '' : '\n'}${nextRawOutput}` + }) + }, []) + + const handleMessage = useCallback( + (_: QueryClient, message: unknown) => { + setIsLoading(false) + setHasReceivedMessage(true) + + const nextImpact = getPreviewImpact(message) + if (nextImpact) { + setImpact(nextImpact) + } + + const nextRawOutput = getPreviewRawOutput(message) + if (nextRawOutput) { + appendRawOutput(nextRawOutput) + } + }, + [appendRawOutput] + ) + + const handleOpen = useCallback(() => { + setIsLoading(true) + setHasError(false) + }, []) + + const handleError = useCallback(() => { + setIsLoading(false) + setHasError(true) + }, []) + + const handleClose = useCallback(() => { + setIsLoading(false) + }, []) + + useReactQueryWsSubscription({ + url: QOVERY_WS + '/blueprint/preview', + urlSearchParams: { + organization: organizationId, + cluster: clusterId, + preview_id: previewId, + }, + enabled: enabled && Boolean(organizationId) && Boolean(clusterId) && Boolean(previewId), + onOpen: handleOpen, + onMessage: handleMessage, + onError: handleError, + onClose: handleClose, + }) + + return { + impact, + rawOutput, + isLoading, + hasError, + hasReceivedMessage, + } +} + +function getPreviewImpact(message: unknown): BlueprintUpdatePreviewImpact | undefined { + const payload = getObjectMessage(message) + if (!payload) return undefined + + const impactPayload = getObjectValue(payload, 'deployment_impact') ?? getObjectValue(payload, 'deploymentImpact') + const source = impactPayload ?? (isImpactMessage(payload) ? payload : undefined) + if (!source) return undefined + + const description = getStringArrayValue(source, 'description') + .concat(getStringArrayValue(source, 'summary')) + .concat(getStringArrayValue(source, 'message')) + const impactedServices = + getStringArrayValue(source, 'impacted_services').length > 0 + ? getStringArrayValue(source, 'impacted_services') + : getStringArrayValue(source, 'impactedServices').concat(getStringArrayValue(source, 'services')) + + return { + level: getImpactLevel(source), + description, + impactedServices, + } +} + +function getPreviewRawOutput(message: unknown): string | undefined { + if (typeof message === 'string') return message + + const payload = getObjectMessage(message) + if (!payload) return undefined + + const rawOutput = + getStringValue(payload, 'payload') ?? + getStringValue(payload, 'raw_output') ?? + getStringValue(payload, 'rawOutput') ?? + getStringValue(payload, 'output') ?? + getStringValue(payload, 'line') ?? + getStringValue(payload, 'log') + + if (rawOutput) return rawOutput + + const messageValue = getStringValue(payload, 'message') + const type = getStringValue(payload, 'type') ?? getStringValue(payload, 'event') + if (messageValue && type && /raw|output|log/i.test(type)) { + return messageValue + } + + const logs = getStringArrayValue(payload, 'logs') + return logs.length > 0 ? logs.join('\n') : undefined +} + +function getObjectMessage(message: unknown): PreviewSocketObjectMessage | undefined { + if (!message || typeof message !== 'object' || Array.isArray(message)) { + return undefined + } + + return message as PreviewSocketObjectMessage +} + +function getObjectValue(payload: PreviewSocketObjectMessage, key: string): PreviewSocketObjectMessage | undefined { + const value = payload[key] + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return undefined + } + + return value as PreviewSocketObjectMessage +} + +function getStringValue(payload: PreviewSocketObjectMessage, key: string): string | undefined { + const value = payload[key] + return typeof value === 'string' && value.length > 0 ? value : undefined +} + +function getStringArrayValue(payload: PreviewSocketObjectMessage, key: string): string[] { + const value = payload[key] + if (typeof value === 'string' && value.length > 0) { + return value.split('\n\n').filter(Boolean) + } + if (Array.isArray(value)) { + return value.filter((item): item is string => typeof item === 'string' && item.length > 0) + } + + return [] +} + +function getImpactLevel(payload: PreviewSocketObjectMessage): BlueprintUpdatePreviewImpactLevel { + const level = + getStringValue(payload, 'level') ?? getStringValue(payload, 'severity') ?? getStringValue(payload, 'impact') + + if (!level) return 'unknown' + + const normalizedLevel = level.toLowerCase() + if (normalizedLevel.includes('high')) return 'high' + if (normalizedLevel.includes('medium')) return 'medium' + if (normalizedLevel.includes('low')) return 'low' + return 'unknown' +} + +function isImpactMessage(payload: PreviewSocketObjectMessage): boolean { + const type = getStringValue(payload, 'type') ?? getStringValue(payload, 'event') + return Boolean(type && /impact|summary|analysis/i.test(type)) +} + +export default useBlueprintUpdatePreviewSocket diff --git a/libs/domains/services/feature/src/lib/hooks/use-preview-blueprint-update/use-preview-blueprint-update.ts b/libs/domains/services/feature/src/lib/hooks/use-preview-blueprint-update/use-preview-blueprint-update.ts new file mode 100644 index 00000000000..dfaccf163bf --- /dev/null +++ b/libs/domains/services/feature/src/lib/hooks/use-preview-blueprint-update/use-preview-blueprint-update.ts @@ -0,0 +1,12 @@ +import { useMutation } from '@tanstack/react-query' +import { mutations } from '@qovery/domains/services/data-access' + +export function usePreviewBlueprintUpdate() { + return useMutation(mutations.previewBlueprintUpdate, { + meta: { + notifyOnError: true, + }, + }) +} + +export default usePreviewBlueprintUpdate diff --git a/libs/domains/services/feature/src/lib/hooks/use-update-blueprint/use-update-blueprint.ts b/libs/domains/services/feature/src/lib/hooks/use-update-blueprint/use-update-blueprint.ts new file mode 100644 index 00000000000..4cef8e0f902 --- /dev/null +++ b/libs/domains/services/feature/src/lib/hooks/use-update-blueprint/use-update-blueprint.ts @@ -0,0 +1,37 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { mutations } from '@qovery/domains/services/data-access' +import { queries } from '@qovery/state/util-queries' + +export function useUpdateBlueprint({ + environmentId, + serviceId, + serviceType, +}: { + environmentId: string + serviceId: string + serviceType: Parameters[0]['serviceType'] +}) { + const queryClient = useQueryClient() + + return useMutation(mutations.updateBlueprint, { + onSuccess(response, { blueprintId }) { + queryClient.invalidateQueries({ + queryKey: queries.services.list(response.environment_id).queryKey, + }) + queryClient.invalidateQueries({ + queryKey: queries.services.details({ serviceId, serviceType }).queryKey, + }) + queryClient.invalidateQueries({ + queryKey: queries.services.blueprintUpdate({ blueprintId }).queryKey, + }) + queryClient.invalidateQueries({ + queryKey: queries.services.listStatuses(environmentId).queryKey, + }) + }, + meta: { + notifyOnError: true, + }, + }) +} + +export default useUpdateBlueprint diff --git a/libs/domains/services/feature/src/lib/service-creation-flow/blueprint/blueprint-configuration-view/blueprint-configuration-view.tsx b/libs/domains/services/feature/src/lib/service-creation-flow/blueprint/blueprint-configuration-view/blueprint-configuration-view.tsx index d3cc7b1348c..191bc8c69c4 100644 --- a/libs/domains/services/feature/src/lib/service-creation-flow/blueprint/blueprint-configuration-view/blueprint-configuration-view.tsx +++ b/libs/domains/services/feature/src/lib/service-creation-flow/blueprint/blueprint-configuration-view/blueprint-configuration-view.tsx @@ -2,8 +2,6 @@ import { useNavigate, useParams } from '@tanstack/react-router' import { useEffect, useMemo, useState } from 'react' import { type Value } from '@qovery/shared/interfaces' import { Button, FunnelFlowBody, Icon, InputSelect, InputText } from '@qovery/shared/ui' -import { usePrefetchBlueprintCatalogServiceManifest } from '../../../hooks/use-blueprint-catalog-service-manifest/use-blueprint-catalog-service-manifest' -import { useBlueprintCreateContext } from '../blueprint-create-context/blueprint-create-context' import { type BlueprintFieldValue, formatFieldLabel, @@ -11,10 +9,12 @@ import { getFieldValidationError, getStringFieldValue, isFieldValid, - sortBlueprintMajorVersions, -} from '../blueprint-creation-utils/blueprint-creation-utils' +} from '../../../blueprint-field-utils/blueprint-field-utils' +import { BlueprintManifestVariableInput } from '../../../blueprint-manifest-variable-input/blueprint-manifest-variable-input' +import { usePrefetchBlueprintCatalogServiceManifest } from '../../../hooks/use-blueprint-catalog-service-manifest/use-blueprint-catalog-service-manifest' +import { useBlueprintCreateContext } from '../blueprint-create-context/blueprint-create-context' +import { sortBlueprintMajorVersions } from '../blueprint-creation-utils/blueprint-creation-utils' import { useBlueprintManifestFields } from '../blueprint-manifest-context/blueprint-manifest-context' -import { BlueprintManifestVariableInput } from './blueprint-creation-components/blueprint-manifest-variable-input/blueprint-manifest-variable-input' import { BlueprintSection } from './blueprint-creation-components/blueprint-section/blueprint-section' import { OverridesSectionCard } from './blueprint-creation-components/overrides-section-card/overrides-section-card' diff --git a/libs/domains/services/feature/src/lib/service-creation-flow/blueprint/blueprint-create-context/blueprint-create-context.ts b/libs/domains/services/feature/src/lib/service-creation-flow/blueprint/blueprint-create-context/blueprint-create-context.ts index 769cadff4a1..691e33fd75e 100644 --- a/libs/domains/services/feature/src/lib/service-creation-flow/blueprint/blueprint-create-context/blueprint-create-context.ts +++ b/libs/domains/services/feature/src/lib/service-creation-flow/blueprint/blueprint-create-context/blueprint-create-context.ts @@ -1,7 +1,7 @@ import { type BlueprintItem } from 'qovery-typescript-axios' import { type Dispatch, type PropsWithChildren, type SetStateAction, createContext, useContext } from 'react' import { type UseFormReturn } from 'react-hook-form' -import { type BlueprintFieldValues } from '../blueprint-creation-utils/blueprint-creation-utils' +import { type BlueprintFieldValues } from '../../../blueprint-field-utils/blueprint-field-utils' export interface BlueprintCreateFormData { serviceName: string diff --git a/libs/domains/services/feature/src/lib/service-creation-flow/blueprint/blueprint-creation-utils/blueprint-creation-utils.ts b/libs/domains/services/feature/src/lib/service-creation-flow/blueprint/blueprint-creation-utils/blueprint-creation-utils.ts index 234434de8f5..d1caa995f42 100644 --- a/libs/domains/services/feature/src/lib/service-creation-flow/blueprint/blueprint-creation-utils/blueprint-creation-utils.ts +++ b/libs/domains/services/feature/src/lib/service-creation-flow/blueprint/blueprint-creation-utils/blueprint-creation-utils.ts @@ -1,27 +1,12 @@ import { type BlueprintMajorVersion, - type BlueprintManifestContextVariableField, - type BlueprintManifestResponseResultsInner, type BlueprintManifestVariableField, type BlueprintVariableRequest, } from 'qovery-typescript-axios' - -export type BlueprintFieldValue = string | boolean -export type BlueprintFieldValues = Record -export type BlueprintFieldPath = `fields.${string}` - -export type OverridableBlueprintManifestContextVariableField = BlueprintManifestContextVariableField & { - overridable?: boolean -} - -export function getBlueprintFieldPath(name: string): BlueprintFieldPath { - return `fields.${name}` -} - -export function formatFieldLabel(name: string) { - const label = name.replace(/_/g, ' ') - return `${label.charAt(0).toUpperCase()}${label.slice(1)}` -} +import { + type BlueprintFieldValues, + type OverridableBlueprintManifestContextVariableField, +} from '../../../blueprint-field-utils/blueprint-field-utils' export function sortBlueprintMajorVersions(versions: BlueprintMajorVersion[]) { return [...versions].sort((a, b) => @@ -32,119 +17,6 @@ export function sortBlueprintMajorVersions(versions: BlueprintMajorVersion[]) { ) } -export function getDefaultFieldValue(field: BlueprintManifestVariableField): BlueprintFieldValue { - if (field.type.type === 'bool') return field.default_value === 'true' - return field.default_value ?? '' -} - -export function getDefaultContextFieldValue(field: BlueprintManifestContextVariableField): BlueprintFieldValue { - return field.value ?? '' -} - -export function getDefaultBlueprintFieldValues(blueprintManifestFields: BlueprintManifestResponseResultsInner[]) { - const requiredBlueprintFields = blueprintManifestFields.filter(isRequiredVariableField) - const optionalBlueprintFields = blueprintManifestFields.filter(isOptionalVariableField) - const overridableContextBlueprintFields = blueprintManifestFields.filter(isOverridableContextVariableField) - const fieldsWithDefaultValue = [...requiredBlueprintFields, ...optionalBlueprintFields] - - return { - ...Object.fromEntries(fieldsWithDefaultValue.map((field) => [field.name, getDefaultFieldValue(field)])), - ...Object.fromEntries( - overridableContextBlueprintFields.map((field) => [field.name, getDefaultContextFieldValue(field)]) - ), - } -} - -export function getStringFieldValue(value: BlueprintFieldValue | undefined) { - return typeof value === 'string' ? value : '' -} - -export function getBooleanFieldValue(value: BlueprintFieldValue | undefined) { - return typeof value === 'boolean' ? value : false -} - -export function isFieldValueFulfilled(value: BlueprintFieldValue | undefined) { - if (typeof value === 'boolean') return true - return Boolean(value?.trim()) -} - -export function isFieldValueMatchingPattern( - field: BlueprintManifestVariableField, - value: BlueprintFieldValue | undefined -) { - if (typeof value !== 'string' || !value || !field.type.pattern) return true - - try { - return new RegExp(field.type.pattern).test(value) - } catch { - return true - } -} - -export function getFieldLengthValidationError( - field: BlueprintManifestVariableField, - value: BlueprintFieldValue | undefined -) { - if (typeof value !== 'string' || !value) return undefined - - const { min_length: minLength, max_length: maxLength } = field.type - const hasMinLength = typeof minLength === 'number' - const hasMaxLength = typeof maxLength === 'number' - - if (hasMinLength && hasMaxLength && (value.length < minLength || value.length > maxLength)) { - return `Value must be between ${minLength} and ${maxLength} characters.` - } - - if (hasMinLength && value.length < minLength) return `Value must be at least ${minLength} characters.` - if (hasMaxLength && value.length > maxLength) return `Value must be at most ${maxLength} characters.` - - return undefined -} - -export function getFieldValidationError(field: BlueprintManifestVariableField, value: BlueprintFieldValue | undefined) { - const lengthValidationError = getFieldLengthValidationError(field, value) - if (lengthValidationError) return lengthValidationError - - if (!isFieldValueMatchingPattern(field, value)) return 'Value does not match the expected format.' - return undefined -} - -export function isFieldValid(field: BlueprintManifestVariableField, value: BlueprintFieldValue | undefined) { - if (field.required && !isFieldValueFulfilled(value)) return false - return !getFieldValidationError(field, value) -} - -export function isVariableField(field: BlueprintManifestResponseResultsInner): field is BlueprintManifestVariableField { - return field.kind === 'variable' -} - -export function isRequiredVariableField( - field: BlueprintManifestResponseResultsInner -): field is BlueprintManifestVariableField { - return isVariableField(field) && field.required -} - -export function isOptionalVariableField( - field: BlueprintManifestResponseResultsInner -): field is BlueprintManifestVariableField { - return isVariableField(field) && !field.required -} - -export function isOverridableContextVariableField( - field: BlueprintManifestResponseResultsInner -): field is OverridableBlueprintManifestContextVariableField { - return field.kind === 'contextVariable' && 'overridable' in field && field.overridable === true -} - -export function getSummaryFieldValue( - field: BlueprintManifestVariableField | OverridableBlueprintManifestContextVariableField, - value: BlueprintFieldValue | undefined -) { - if (typeof value === 'boolean') return value ? 'Enabled' : 'Disabled' - if (field.kind === 'variable' && field.is_secret && value) return '••••••••' - return value -} - export function buildBlueprintVariables( fields: BlueprintFieldValues, blueprintFields: Array diff --git a/libs/domains/services/feature/src/lib/service-creation-flow/blueprint/blueprint-manifest-context/blueprint-manifest-context.tsx b/libs/domains/services/feature/src/lib/service-creation-flow/blueprint/blueprint-manifest-context/blueprint-manifest-context.tsx index f1de20d906d..88f0e088a3a 100644 --- a/libs/domains/services/feature/src/lib/service-creation-flow/blueprint/blueprint-manifest-context/blueprint-manifest-context.tsx +++ b/libs/domains/services/feature/src/lib/service-creation-flow/blueprint/blueprint-manifest-context/blueprint-manifest-context.tsx @@ -4,15 +4,15 @@ import { type BlueprintManifestVariableField, } from 'qovery-typescript-axios' import { type PropsWithChildren, createContext, useContext, useEffect, useMemo } from 'react' -import { useBlueprintCatalogServiceManifest } from '../../../hooks/use-blueprint-catalog-service-manifest/use-blueprint-catalog-service-manifest' -import { useBlueprintCreateContext } from '../blueprint-create-context/blueprint-create-context' import { type OverridableBlueprintManifestContextVariableField, getDefaultBlueprintFieldValues, isOptionalVariableField, isOverridableContextVariableField, isRequiredVariableField, -} from '../blueprint-creation-utils/blueprint-creation-utils' +} from '../../../blueprint-field-utils/blueprint-field-utils' +import { useBlueprintCatalogServiceManifest } from '../../../hooks/use-blueprint-catalog-service-manifest/use-blueprint-catalog-service-manifest' +import { useBlueprintCreateContext } from '../blueprint-create-context/blueprint-create-context' interface BlueprintManifestContextInterface { blueprintManifestFields: BlueprintManifestResponseResultsInner[] diff --git a/libs/domains/services/feature/src/lib/service-creation-flow/blueprint/blueprint-step-summary/blueprint-step-summary.tsx b/libs/domains/services/feature/src/lib/service-creation-flow/blueprint/blueprint-step-summary/blueprint-step-summary.tsx index 197a89d65cc..259792b591b 100644 --- a/libs/domains/services/feature/src/lib/service-creation-flow/blueprint/blueprint-step-summary/blueprint-step-summary.tsx +++ b/libs/domains/services/feature/src/lib/service-creation-flow/blueprint/blueprint-step-summary/blueprint-step-summary.tsx @@ -3,15 +3,15 @@ import posthog from 'posthog-js' import { type BlueprintCreateRequest } from 'qovery-typescript-axios' import { useCallback, useEffect, useRef, useState } from 'react' import { Button, FunnelFlowBody, Heading, Icon, Section, SummaryValue, toast } from '@qovery/shared/ui' -import { useBlueprintServiceCreatedSocket } from '../../../hooks/use-blueprint-service-created-socket/use-blueprint-service-created-socket' -import { useCreateBlueprint } from '../../../hooks/use-create-blueprint/use-create-blueprint' -import { useBlueprintCreateContext } from '../blueprint-create-context/blueprint-create-context' import { - buildBlueprintVariables, formatFieldLabel, getSummaryFieldValue, isFieldValid, -} from '../blueprint-creation-utils/blueprint-creation-utils' +} from '../../../blueprint-field-utils/blueprint-field-utils' +import { useBlueprintServiceCreatedSocket } from '../../../hooks/use-blueprint-service-created-socket/use-blueprint-service-created-socket' +import { useCreateBlueprint } from '../../../hooks/use-create-blueprint/use-create-blueprint' +import { useBlueprintCreateContext } from '../blueprint-create-context/blueprint-create-context' +import { buildBlueprintVariables } from '../blueprint-creation-utils/blueprint-creation-utils' import { useBlueprintManifestFields } from '../blueprint-manifest-context/blueprint-manifest-context' type BlueprintConfigurationSection = 'service-information' | 'blueprint-setup' | 'overrides' diff --git a/libs/domains/services/feature/src/lib/service-overview/service-header/service-header.spec.tsx b/libs/domains/services/feature/src/lib/service-overview/service-header/service-header.spec.tsx index 91f595522d7..150a7700168 100644 --- a/libs/domains/services/feature/src/lib/service-overview/service-header/service-header.spec.tsx +++ b/libs/domains/services/feature/src/lib/service-overview/service-header/service-header.spec.tsx @@ -8,6 +8,9 @@ import { ServiceHeader } from './service-header' const mockCopyToClipboard = jest.fn() const mockGetDatabaseConnectionUri = jest.fn(() => 'postgres://copied-uri') const mockUseBlueprintUpdate = jest.fn() +const mockOpenModal = jest.fn() +const mockCloseModal = jest.fn() +const mockNavigate = jest.fn() const services = { 'application-mock': { id: 'ebb84aa8-91c2-40fb-916d-3a158db354b7', @@ -90,12 +93,19 @@ const services = { }, }, blueprint_id: 'blueprint-id', + tag: 'aws/s3/1.2', }, } jest.mock('@tanstack/react-router', () => ({ ...jest.requireActual('@tanstack/react-router'), - useParams: () => ({ organizationId: '', projectId: '' }), + useNavigate: () => mockNavigate, + useParams: () => ({ + organizationId: 'org-id', + projectId: 'project-id', + environmentId: 'environment-id', + serviceId: 'terraform-mock', + }), Link: ({ children, to, @@ -117,6 +127,10 @@ jest.mock('@qovery/shared/ui', () => ({ ...jest.requireActual('@qovery/shared/ui'), toast: jest.fn(), Heading: ({ children }: { children?: ReactNode }) =>

{children}

, + useModal: () => ({ + openModal: mockOpenModal, + closeModal: mockCloseModal, + }), })) jest.mock('@qovery/shared/util-hooks', () => ({ @@ -126,6 +140,13 @@ jest.mock('@qovery/shared/util-hooks', () => ({ jest.mock('@qovery/domains/clusters/feature', () => ({ ...jest.requireActual('@qovery/domains/clusters/feature'), + ClusterRunningStatusIndicator: () =>
cluster-running-status
, + useCluster: () => ({ + data: { + id: 'cluster-id', + name: 'my-cluster', + }, + }), useClusterRunningStatusSocket: jest.fn(), })) @@ -376,6 +397,14 @@ describe('ServiceHeader', () => { data: { is_up_to_date: true, latest_tag: 'aws/s3/1.0.0', + new_required_values: [], + new_optional_values: [], + now_required_values: [], + updated_values: [], + removed_values: [], + engine_diff: { + updated_values: [], + }, }, }) @@ -386,18 +415,48 @@ describe('ServiceHeader', () => { expect(screen.queryByText('Update available')).not.toBeInTheDocument() }) - it('renders an update available badge for an outdated blueprint service', () => { + it('opens the blueprint update modal from the update available badge', async () => { mockUseBlueprintUpdate.mockReturnValue({ data: { is_up_to_date: false, - latest_tag: 'aws/s3/2.0.0', + latest_tag: 'aws/s3/2.0', + new_required_values: [], + new_optional_values: [], + now_required_values: [], + updated_values: [], + removed_values: [], + engine_diff: { + updated_values: [], + }, }, }) - renderServiceHeader('terraform-mock') + const { userEvent } = renderServiceHeader('terraform-mock') - expect(screen.getByText('Update available')).toBeInTheDocument() + await userEvent.click(screen.getByRole('button', { name: /update available/i })) + + expect(mockOpenModal).toHaveBeenCalledTimes(1) + renderWithProviders(mockOpenModal.mock.calls[0][0].content) + + expect(screen.getAllByText('Update available')[0]).toBeInTheDocument() + expect(screen.getByRole('heading', { name: 'Blueprint update from 1.2 to 2.0' })).toBeInTheDocument() + expect(screen.getByRole('link', { name: 'release notes' })).toBeInTheDocument() + expect(screen.queryByText('0')).not.toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Dismiss' })).toBeInTheDocument() expect(screen.queryByText('Up to date')).not.toBeInTheDocument() + + await userEvent.click(screen.getByRole('button', { name: 'Review & update' })) + + expect(mockCloseModal).toHaveBeenCalled() + expect(mockNavigate).toHaveBeenCalledWith({ + to: '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/update/blueprint', + params: { + organizationId: 'org-id', + projectId: 'project-id', + environmentId: 'environment-id', + serviceId: 'terraform-mock', + }, + }) }) it('renders a skeleton while the blueprint update badge is loading', () => { diff --git a/libs/domains/services/feature/src/lib/service-overview/service-header/service-header.tsx b/libs/domains/services/feature/src/lib/service-overview/service-header/service-header.tsx index a8f01a93036..e5e4cda4e0a 100644 --- a/libs/domains/services/feature/src/lib/service-overview/service-header/service-header.tsx +++ b/libs/domains/services/feature/src/lib/service-overview/service-header/service-header.tsx @@ -1,5 +1,10 @@ -import { Link, useParams } from '@tanstack/react-router' -import { type ApplicationGitRepository, type Credentials, type Environment } from 'qovery-typescript-axios' +import { Link, useNavigate, useParams } from '@tanstack/react-router' +import { + type ApplicationGitRepository, + type BlueprintUpdateResponse, + type Credentials, + type Environment, +} from 'qovery-typescript-axios' import { Suspense } from 'react' import { P, match } from 'ts-pattern' import { @@ -28,6 +33,7 @@ import { Tooltip, Truncate, toast, + useModal, } from '@qovery/shared/ui' import { buildGitProviderUrl } from '@qovery/shared/util-git' import { useCopyToClipboard } from '@qovery/shared/util-hooks' @@ -163,8 +169,81 @@ function BlueprintUpdateBadgeSkeleton() { return } -function BlueprintUpdateBadge({ blueprintId }: { blueprintId: string }) { +interface BlueprintUpdateModalProps { + blueprintUpdate: BlueprintUpdateResponse + currentVersion?: string + onClose: () => void + onReviewUpdate: () => void +} + +const BLUEPRINT_RELEASE_NOTES_URL = 'https://github.com/Qovery/service-catalog/releases' + +function getVersionFromTag(tag?: string) { + return tag?.split('/').at(-1) +} + +function getServiceBlueprintVersion(service: AnyService) { + if ('tag' in service && typeof service.tag === 'string') { + return getVersionFromTag(service.tag) + } + + return undefined +} + +function BlueprintUpdateModal({ blueprintUpdate, currentVersion, onClose, onReviewUpdate }: BlueprintUpdateModalProps) { + const latestVersion = getVersionFromTag(blueprintUpdate.latest_tag) ?? blueprintUpdate.latest_tag + + return ( +
+
+

+ Blueprint update from {currentVersion ?? 'current version'} to {latestVersion} +

+

+ This update improves bucket performance for large object transfers and hardens IAM permission scoping. See the + full{' '} + + release notes + {' '} + for more details. +

+
+ +
+ + +
+
+ ) +} + +function BlueprintUpdateBadge({ blueprintId, service }: { blueprintId: string; service: AnyService }) { const { data: blueprintUpdate } = useBlueprintUpdate({ blueprintId, suspense: true }) + const { openModal, closeModal } = useModal() + const navigate = useNavigate() + const { + organizationId = '', + projectId = '', + environmentId = '', + serviceId = service.id, + } = useParams({ strict: false }) + const currentVersion = getServiceBlueprintVersion(service) + const openUpdateFlow = () => { + closeModal() + navigate({ + to: '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/update/blueprint', + params: { organizationId, projectId, environmentId, serviceId }, + }) + } if (!blueprintUpdate) { return null @@ -176,12 +255,27 @@ function BlueprintUpdateBadge({ blueprintId }: { blueprintId: string }) { Up to date ) : ( - + ) } @@ -325,7 +419,7 @@ function ServiceHeaderMetadata({ service }: ServiceHeaderMetadataProps) { )} {blueprintId && ( }> - + )} {databaseSource && ( diff --git a/libs/domains/services/feature/src/lib/service-update-flow/blueprint/blueprint-update-flow.tsx b/libs/domains/services/feature/src/lib/service-update-flow/blueprint/blueprint-update-flow.tsx new file mode 100644 index 00000000000..b553bb4fd3e --- /dev/null +++ b/libs/domains/services/feature/src/lib/service-update-flow/blueprint/blueprint-update-flow.tsx @@ -0,0 +1,870 @@ +import { type IconName } from '@fortawesome/fontawesome-common-types' +import { + type BlueprintManifestVariableField, + type BlueprintUpdateEngineFieldChange, + type BlueprintUpdateNewOptionalValue, + type BlueprintUpdateNewRequiredValue, + type BlueprintUpdateRemovedValue, + type BlueprintUpdateUpdatedValue, +} from 'qovery-typescript-axios' +import { type ReactNode, useEffect, useState } from 'react' +import { type AnyService } from '@qovery/domains/services/data-access' +import { Button, FunnelFlowBody, Icon, InputText, LogoIcon, Skeleton, Tooltip, toast } from '@qovery/shared/ui' +import { + type BlueprintFieldValue, + type BlueprintFieldValues, + getFieldValidationError, + isFieldValid, +} from '../../blueprint-field-utils/blueprint-field-utils' +import { BlueprintManifestVariableInput } from '../../blueprint-manifest-variable-input/blueprint-manifest-variable-input' +import { + type BlueprintUpdatePreviewImpact, + type BlueprintUpdatePreviewImpactLevel, + useBlueprintUpdatePreviewSocket, +} from '../../hooks/use-blueprint-update-preview-socket/use-blueprint-update-preview-socket' +import { useBlueprintUpdate } from '../../hooks/use-blueprint-update/use-blueprint-update' +import { usePreviewBlueprintUpdate } from '../../hooks/use-preview-blueprint-update/use-preview-blueprint-update' +import { useUpdateBlueprint } from '../../hooks/use-update-blueprint/use-update-blueprint' + +type BlueprintUpdateSection = 'required' | 'optional' | 'modified' | 'removed' + +type BlueprintUpdateVariablePatch = Record +type BlueprintUpdateField = + | BlueprintUpdateNewRequiredValue + | BlueprintUpdateNewOptionalValue + | BlueprintUpdateUpdatedValue +type BlueprintUpdateEditableValue = BlueprintUpdateUpdatedValue | BlueprintUpdateEngineFieldChange + +export interface BlueprintUpdateFlowProps { + blueprintId: string + clusterId?: string + environmentId: string + onExit: () => void + organizationId: string + service: AnyService +} + +const updateSections: Array<{ + id: BlueprintUpdateSection + title: string + iconName: IconName + description?: string +}> = [ + { + id: 'required', + title: 'New required values', + iconName: 'circle-plus', + description: + "These are new fields we have added that don't have a default value and require you to enter a specific value for the update.", + }, + { + id: 'optional', + title: 'New optional values', + iconName: 'chart-bullet', + description: 'These new fields have a default value, but you can override them if needed.', + }, + { + id: 'modified', + title: 'Modified values', + iconName: 'arrows-rotate', + description: "Existing values that I've seen their default updated. Your overrides stays prioritaire.", + }, + { + id: 'removed', + title: 'Removed values', + iconName: 'circle-minus', + }, +] + +export function BlueprintUpdateFlow({ + blueprintId, + clusterId, + environmentId, + onExit, + organizationId, + service, +}: BlueprintUpdateFlowProps) { + const { data: blueprintUpdate } = useBlueprintUpdate({ blueprintId, suspense: true }) + const { + mutateAsync: previewBlueprintUpdate, + data: preview, + isLoading: isPreviewLoading, + } = usePreviewBlueprintUpdate() + const { mutateAsync: updateBlueprint, isLoading: isUpdateLoading } = useUpdateBlueprint({ + environmentId, + serviceId: service.id, + serviceType: service.service_type, + }) + const [currentStep, setCurrentStep] = useState<'review' | 'preview'>('review') + const [activeSection, setActiveSection] = useState(() => + blueprintUpdate ? getFirstAvailableUpdateSection(blueprintUpdate) : 'required' + ) + const [completedSections, setCompletedSections] = useState([]) + const [values, setValues] = useState({}) + const [initializedBlueprintId, setInitializedBlueprintId] = useState() + + useEffect(() => { + if (blueprintUpdate && initializedBlueprintId !== blueprintId) { + setValues(getInitialUpdateValues(blueprintUpdate)) + setActiveSection(getFirstAvailableUpdateSection(blueprintUpdate)) + setInitializedBlueprintId(blueprintId) + } + }, [blueprintId, blueprintUpdate, initializedBlueprintId]) + + if (!blueprintUpdate) return null + + const requiredValues = [...blueprintUpdate.new_required_values, ...blueprintUpdate.now_required_values] + const sectionHasContent = { + required: requiredValues.length > 0, + optional: blueprintUpdate.new_optional_values.length > 0, + modified: blueprintUpdate.updated_values.length > 0 || blueprintUpdate.engine_diff.updated_values.length > 0, + removed: blueprintUpdate.removed_values.length > 0, + } + const visibleSections = updateSections.filter(({ id }) => sectionHasContent[id]) + const reviewSections = visibleSections.length > 0 ? visibleSections : updateSections.slice(0, 1) + const activeSectionIndex = reviewSections.findIndex(({ id }) => id === activeSection) + const isRequiredValid = requiredValues.every((value) => + isFieldValid(getBlueprintUpdateVariableField(value, true), values[value.name]) + ) + const isReviewComplete = + reviewSections.every(({ id }) => completedSections.includes(id)) || reviewSections.length === 0 + const latestVersion = getVersionFromTag(blueprintUpdate.latest_tag) ?? blueprintUpdate.latest_tag + const title = `${service.name} blueprint update to ${latestVersion}` + const payload = buildBlueprintUpdatePayload({ + icon: service.icon_uri ?? getFallbackServiceIcon(service.service_type), + name: service.name, + tag: blueprintUpdate.latest_tag, + values, + optionalValues: blueprintUpdate.new_optional_values, + requiredValues, + updatedValues: [...blueprintUpdate.updated_values, ...blueprintUpdate.engine_diff.updated_values], + }) + + const completeActiveSection = () => { + if (activeSection === 'required' && !isRequiredValid) return + const nextCompletedSections = completedSections.includes(activeSection) + ? completedSections + : [...completedSections, activeSection] + const nextSection = reviewSections[activeSectionIndex + 1]?.id + + setCompletedSections(nextCompletedSections) + if (nextSection) setActiveSection(nextSection) + } + + const handlePreview = async () => { + if (!isReviewComplete) return + + await previewBlueprintUpdate({ blueprintId, payload }) + setCurrentStep('preview') + } + + const handleUpdate = async () => { + await updateBlueprint({ blueprintId, payload }) + toast('success', 'Blueprint update started') + onExit() + } + + return ( + + {currentStep === 'review' ? ( + +
+

{title}

+
+ +
+ {reviewSections.map((section) => ( + setActiveSection(section.id)} + > + {activeSection === section.id && ( + setValues((currentValues) => ({ ...currentValues, [name]: value }))} + onContinue={completeActiveSection} + continueDisabled={section.id === 'required' && !isRequiredValid} + /> + )} + + ))} +
+ +
+ +
+
+ ) : ( + setCurrentStep('review')} + onConfirm={handleUpdate} + loading={isUpdateLoading} + /> + )} +
+ ) +} + +function BlueprintUpdateFlowShell({ + children, + currentStep, + onExit, +}: { + children: ReactNode + currentStep: 1 | 2 + onExit: () => void +}) { + return ( +
+
+
+
+ +
+
+ 1} active={currentStep === 1} number={1} title="Review update" /> + + +
+
+
+ +
+
+
{children}
+
+ ) +} + +function StepIndicator({ + active, + completed, + number, + title, +}: { + active?: boolean + completed?: boolean + number: number + title: string +}) { + return ( +
+ {completed ? ( + + ) : ( + + {number} + + )} + + {title} + +
+ ) +} + +function BlueprintUpdateSectionCard({ + active, + children, + completed, + description, + iconName, + onClick, + title, +}: { + active: boolean + children: ReactNode + completed: boolean + description?: string + iconName: IconName + onClick: () => void + title: string +}) { + return ( +
+ + {children &&
{children}
} +
+ ) +} + +function BlueprintUpdateSectionContent({ + continueDisabled, + onChange, + onContinue, + optionalValues, + removedValues, + requiredValues, + section, + updatedValues, + values, +}: { + continueDisabled: boolean + onChange: (name: string, value: BlueprintFieldValue) => void + onContinue: () => void + optionalValues: BlueprintUpdateNewOptionalValue[] + removedValues: BlueprintUpdateRemovedValue[] + requiredValues: BlueprintUpdateNewRequiredValue[] + section: BlueprintUpdateSection + updatedValues: BlueprintUpdateEditableValue[] + values: BlueprintFieldValues +}) { + return ( + <> + {section === 'required' && ( +
+ {requiredValues.map((value, index) => { + const field = getBlueprintUpdateVariableField(value, true) + + return ( + onChange(value.name, fieldValue)} + /> + ) + })} +
+ )} + + {section === 'optional' && ( +
+ {optionalValues.map((value, index) => { + const field = getBlueprintUpdateVariableField(value, false, value.default_value) + + return ( + onChange(value.name, fieldValue)} + /> + ) + })} +
+ )} + + {section === 'modified' && ( + + )} + + {section === 'removed' && } + + + + ) +} + +function UpdatedValuesList({ + editableValues, + onChange, + values, +}: { + editableValues: BlueprintFieldValues + onChange: (name: string, value: BlueprintFieldValue) => void + values: BlueprintUpdateEditableValue[] +}) { + const [editedValueName, setEditedValueName] = useState() + + if (values.length === 0) return

No modified values.

+ + return ( +
+ {values.map((value) => { + const label = formatUpdateFieldLabel(value.name) + const hasManualOverride = Object.prototype.hasOwnProperty.call(editableValues, value.name) + const editedValue = hasManualOverride + ? editableValues[value.name] ?? '' + : getBlueprintUpdateFieldValue(value, value.current_value) + const newDefaultValue = value.new_default_value ?? '' + const hasEditedOverride = hasManualOverride && getBlueprintUpdatePayloadValue(editedValue) !== newDefaultValue + const editing = editedValueName === value.name + const canUseTypedInput = isBlueprintUpdateVariableField(value) + const field = canUseTypedInput + ? getBlueprintUpdateVariableField(value, false, value.new_default_value) + : undefined + + return ( +
+
+
+

{label}

+
+
+ Default: + {formatUpdateValue(value.current_default_value)} + + {formatUpdateValue(value.new_default_value)} +
+ {hasEditedOverride && ( +
+ Override: + {formatUpdateValue(editedValue)} +
+ )} +
+
+ +
+ {hasEditedOverride && ( + Reset the override to the new default value}> + + + )} + + + +
+
+ + {editing && + (field ? ( + onChange(value.name, fieldValue)} + /> + ) : ( + onChange(value.name, event.currentTarget.value)} + /> + ))} +
+ ) + })} +
+ ) +} + +function RemovedValuesList({ values }: { values: Array<{ name: string }> }) { + if (values.length === 0) return

No removed values.

+ + return ( +
+ {values.map((value) => ( +
+

{formatUpdateFieldLabel(value.name)}

+ Deleted +
+ ))} +
+ ) +} + +function CodeChip({ children, color = 'neutral' }: { children: ReactNode; color?: 'neutral' | 'info' | 'negative' }) { + const className = { + neutral: 'border-neutral bg-surface-neutral-component text-neutral', + info: 'border-info-subtle bg-surface-info-component text-info', + negative: 'border-negative-subtle bg-surface-negative-component text-negative', + }[color] + + return {children} +} + +function BlueprintUpdatePreview({ + clusterId, + loading, + onBack, + onConfirm, + organizationId, + previewId, +}: { + clusterId?: string + loading: boolean + onBack: () => void + onConfirm: () => void + organizationId: string + previewId?: string +}) { + const { + impact, + rawOutput, + isLoading: isPreviewOutputLoading, + hasError: hasPreviewOutputError, + hasReceivedMessage: hasReceivedPreviewMessage, + } = useBlueprintUpdatePreviewSocket({ + organizationId, + clusterId, + previewId, + }) + + return ( + +
+

Preview changes

+ +
+
+

Estimated deployment impact

+
+ Generated by AI + +
+
+ +
+ +
+

Raw output

+
+ {rawOutput ? ( +
{rawOutput}
+ ) : previewId ? ( +

+ {hasPreviewOutputError ? 'Unable to load preview output.' : 'Waiting for preview output…'} +

+ ) : ( + + )} +
+
+
+ +
+ + +
+
+ ) +} + +function PreviewImpactCard({ + hasError, + impact, + loading, +}: { + hasError: boolean + impact?: BlueprintUpdatePreviewImpact + loading: boolean +}) { + if (loading) { + return ( +
+ +
+ + + +
+
+ ) + } + + if (!impact || (impact.description.length === 0 && impact.impactedServices.length === 0)) { + return ( +
+
+ + {hasError ? 'Preview unavailable' : 'Preview pending'} +
+

+ {hasError + ? 'The deployment impact could not be loaded. You can go back and generate the preview again.' + : 'Deployment impact will appear here when available.'} +

+
+ ) + } + + return ( +
+ + {impact.description.length > 0 && ( +
+ {impact.description.map((description) => ( +

{description}

+ ))} +
+ )} + {impact.impactedServices.length > 0 && ( +
+

Impacted services

+
+ {impact.impactedServices.map((service) => ( + {service} + ))} +
+
+ )} +
+ ) +} + +function PreviewImpactBadge({ level }: { level: BlueprintUpdatePreviewImpactLevel }) { + const badgeClassName = { + high: 'border-negative-component bg-surface-negative-component text-negative', + medium: 'border-warning-component bg-surface-warning-component text-warning', + low: 'border-positive-component bg-surface-positive-component text-positive', + unknown: 'border-info-subtle bg-surface-info-component text-info', + }[level] + + const iconName = level === 'high' || level === 'medium' ? 'circle-exclamation' : 'circle-info' + + return ( +
+ + {getImpactLabel(level)} +
+ ) +} + +function getImpactLabel(level: BlueprintUpdatePreviewImpactLevel) { + return `${level.charAt(0).toUpperCase()}${level.slice(1)} impact` +} + +function getInitialUpdateValues(blueprintUpdate: NonNullable['data']>) { + return { + ...Object.fromEntries( + blueprintUpdate.new_optional_values.map((value) => [ + value.name, + getBlueprintUpdateFieldValue(value, value.default_value), + ]) + ), + } +} + +function getFirstAvailableUpdateSection( + blueprintUpdate: NonNullable['data']> +): BlueprintUpdateSection { + if (blueprintUpdate.new_required_values.length > 0 || blueprintUpdate.now_required_values.length > 0) { + return 'required' + } + if (blueprintUpdate.new_optional_values.length > 0) return 'optional' + if (blueprintUpdate.updated_values.length > 0 || blueprintUpdate.engine_diff.updated_values.length > 0) { + return 'modified' + } + if (blueprintUpdate.removed_values.length > 0) return 'removed' + return 'required' +} + +function buildBlueprintUpdatePayload({ + icon, + name, + optionalValues, + requiredValues, + tag, + updatedValues, + values, +}: { + icon: string + name: string + optionalValues: BlueprintUpdateNewOptionalValue[] + requiredValues: BlueprintUpdateNewRequiredValue[] + tag: string + updatedValues: BlueprintUpdateEditableValue[] + values: BlueprintFieldValues +}) { + const variables: BlueprintUpdateVariablePatch = {} + + requiredValues.forEach((field) => { + const value = getBlueprintUpdatePayloadValue(values[field.name]) + if (value) variables[field.name] = { value, is_secret: field.is_secret } + }) + optionalValues.forEach((field) => { + const value = getBlueprintUpdatePayloadValue(values[field.name]) + if (value && value !== field.default_value) variables[field.name] = { value, is_secret: field.is_secret } + }) + updatedValues.forEach((field) => { + const value = getBlueprintUpdatePayloadValue(values[field.name]) + if (!value || value === field.new_default_value) return + + variables[field.name] = { + value, + ...(isBlueprintUpdateVariableField(field) ? { is_secret: field.is_secret } : {}), + } + }) + + return { + name, + tag, + icon, + variables, + } +} + +function getBlueprintUpdateVariableField( + field: BlueprintUpdateField, + required: boolean, + defaultValue?: string | null +): BlueprintManifestVariableField { + return { + kind: 'variable', + name: field.name, + type: field.type, + required, + is_secret: field.is_secret, + allowed_values: field.allowed_values, + default_value: defaultValue, + } +} + +function isBlueprintUpdateVariableField( + field: BlueprintUpdateEditableValue | BlueprintUpdateNewOptionalValue +): field is BlueprintUpdateUpdatedValue | BlueprintUpdateNewOptionalValue { + return 'type' in field && 'is_secret' in field +} + +function getBlueprintUpdateFieldValue( + field: BlueprintUpdateEditableValue | BlueprintUpdateNewOptionalValue, + value?: string | null +): BlueprintFieldValue { + if (isBlueprintUpdateVariableField(field) && field.type.type === 'bool' && !field.allowed_values?.length) { + return value === 'true' + } + + return value ?? '' +} + +function getBlueprintUpdatePayloadValue(value: BlueprintFieldValue | undefined) { + if (typeof value === 'boolean') return String(value) + + const trimmedValue = value?.trim() + return trimmedValue ? trimmedValue : undefined +} + +function formatUpdateFieldLabel(name: string) { + const label = name.replace(/_/g, ' ') + return `${label.charAt(0).toUpperCase()}${label.slice(1)}` +} + +function formatUpdateValue(value?: BlueprintFieldValue | string | null) { + if (typeof value === 'boolean') return String(value) + return value && value.length > 0 ? value : '-' +} + +function getVersionFromTag(tag: string) { + return tag.split('/').filter(Boolean).at(-1) +} + +function getFallbackServiceIcon(serviceType: AnyService['service_type']) { + if (serviceType === 'HELM') return 'app://qovery-console/helm' + if (serviceType === 'TERRAFORM') return 'app://qovery-console/terraform' + return 'app://qovery-console/application' +} + +export default BlueprintUpdateFlow diff --git a/libs/state/util-queries/src/lib/use-react-query-ws-subscription/use-react-query-ws-subscription.spec.tsx b/libs/state/util-queries/src/lib/use-react-query-ws-subscription/use-react-query-ws-subscription.spec.tsx index 50e220f3020..256a2a35bdc 100644 --- a/libs/state/util-queries/src/lib/use-react-query-ws-subscription/use-react-query-ws-subscription.spec.tsx +++ b/libs/state/util-queries/src/lib/use-react-query-ws-subscription/use-react-query-ws-subscription.spec.tsx @@ -101,6 +101,22 @@ describe('useReactQueryWsSubscription', () => { unmount() }) + it('should call onMessage handler with plain text messages', async () => { + server.close() + server = new WS('ws://localhost:4321') + const onMessage = jest.fn() + const { unmount } = renderHook(() => useReactQueryWsSubscription({ url: 'ws://localhost:4321', onMessage })) + const queryClient = useQueryClient() + const connection = await server.connected + + server.send('raw output line') + + expect(queryClient.invalidateQueries).not.toHaveBeenCalled() + expect(onMessage).toHaveBeenNthCalledWith(1, queryClient, 'raw output line') + connection.close() + unmount() + }) + it('should do nothing when not enabled', async () => { const onMessage = jest.fn() const { unmount } = renderHook(() => diff --git a/libs/state/util-queries/src/lib/use-react-query-ws-subscription/use-react-query-ws-subscription.ts b/libs/state/util-queries/src/lib/use-react-query-ws-subscription/use-react-query-ws-subscription.ts index 9c7d1b96f99..9854f204ff5 100644 --- a/libs/state/util-queries/src/lib/use-react-query-ws-subscription/use-react-query-ws-subscription.ts +++ b/libs/state/util-queries/src/lib/use-react-query-ws-subscription/use-react-query-ws-subscription.ts @@ -32,6 +32,18 @@ function isInvalidateOperation(data: any): data is InvalidateOperation { return Array.isArray(data?.entity) } +function parseWebSocketMessageData(data: MessageEvent['data']) { + if (typeof data !== 'string') { + return data + } + + try { + return JSON.parse(data) + } catch { + return data + } +} + // TODO: Add better naming for the hook we can use it without ReactQuery export function useReactQueryWsSubscription({ url, @@ -87,7 +99,7 @@ export function useReactQueryWsSubscription({ onOpen?.(queryClient, event) } websocket.onmessage = async (event) => { - const data = typeof event.data === 'string' ? JSON.parse(event.data) : event.data + const data = parseWebSocketMessageData(event.data) if (isInvalidateOperation(data)) { const queryKey = [...data.entity, data.id].filter(Boolean) diff --git a/package.json b/package.json index c7c4212cafb..d9920530f5f 100644 --- a/package.json +++ b/package.json @@ -77,7 +77,7 @@ "mermaid": "11.6.0", "monaco-editor": "0.53.0", "posthog-js": "1.345.1", - "qovery-typescript-axios": "1.1.919", + "qovery-typescript-axios": "1.1.920", "react": "18.3.1", "react-country-flag": "3.0.2", "react-datepicker": "4.12.0", diff --git a/yarn.lock b/yarn.lock index bad0e7431eb..f8ddeabf67c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6343,7 +6343,7 @@ __metadata: prettier: 3.2.5 prettier-plugin-tailwindcss: 0.5.14 pretty-quick: 4.0.0 - qovery-typescript-axios: 1.1.919 + qovery-typescript-axios: 1.1.920 qovery-ws-typescript-axios: 0.1.586 react: 18.3.1 react-country-flag: 3.0.2 @@ -25862,12 +25862,12 @@ __metadata: languageName: node linkType: hard -"qovery-typescript-axios@npm:1.1.919": - version: 1.1.919 - resolution: "qovery-typescript-axios@npm:1.1.919" +"qovery-typescript-axios@npm:1.1.920": + version: 1.1.920 + resolution: "qovery-typescript-axios@npm:1.1.920" dependencies: axios: 1.15.2 - checksum: dea1b215b607bc3e9c94ab1faa5389f5d1319981f0a775c7bfab2a1e1c42ba42c1e6893b7d6763b8a19e0856156dd116adf4e8aa07503d43237e29ac0dd4e553 + checksum: ea8634a4c39cffb4127fd69ff1e9f59948db6ab35ddce30afd8a32312ae5a2394e88196b6ab25f47bf5a8d1801c839d5b63e8d7ca708da1da77eb9bdf6fe0e68 languageName: node linkType: hard