()
+
+ 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' ? (
+
+
+
+
+ {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 (
+
+ )
+}
+
+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