From a765b3c18b97a715b597d06b258837eefc358798 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9mi=20Bonnet?= Date: Tue, 30 Jun 2026 17:20:57 +0200 Subject: [PATCH 01/15] chore(release): add production promotion workflow (#2786) --- .github/workflows/promote-production.yml | 162 +++++++++++++++++++++++ 1 file changed, 162 insertions(+) create mode 100644 .github/workflows/promote-production.yml diff --git a/.github/workflows/promote-production.yml b/.github/workflows/promote-production.yml new file mode 100644 index 00000000000..e4520d8c222 --- /dev/null +++ b/.github/workflows/promote-production.yml @@ -0,0 +1,162 @@ +name: Open production promotion PR + +on: + workflow_dispatch: + inputs: + confirm: + description: 'Type "confirm" to open the production promotion PR' + required: true + auto_merge: + description: 'Enable auto-merge once branch protection requirements are met' + required: true + default: true + type: boolean + +permissions: + checks: read + contents: write + pull-requests: write + statuses: read + +jobs: + open-promotion-pr: + name: Open staging to main PR + runs-on: ubuntu-latest + steps: + - name: Validate confirmation + if: ${{ inputs.confirm != 'confirm' }} + run: | + echo 'Invalid confirmation. Type "confirm" to open the production promotion PR.' + exit 1 + + - name: Check if staging has changes to promote + id: compare + env: + GH_TOKEN: ${{ secrets.GH_TOKEN }} + REPOSITORY: ${{ github.repository }} + run: | + ahead_by="$(gh api "repos/${REPOSITORY}/compare/main...staging" --jq '.ahead_by')" + echo "ahead_by=${ahead_by}" >> "$GITHUB_OUTPUT" + + if [ "$ahead_by" = "0" ]; then + echo "staging is already aligned with main. Nothing to promote." + fi + + - name: Find existing promotion PR + id: existing-pr + if: ${{ steps.compare.outputs.ahead_by != '0' }} + env: + GH_TOKEN: ${{ secrets.GH_TOKEN }} + REPOSITORY: ${{ github.repository }} + run: | + pr_url="$(gh pr list \ + --repo "$REPOSITORY" \ + --base main \ + --head staging \ + --state open \ + --json url \ + --jq '.[0].url')" + + echo "url=${pr_url}" >> "$GITHUB_OUTPUT" + + - name: Create promotion PR + id: create-pr + if: ${{ steps.compare.outputs.ahead_by != '0' && steps.existing-pr.outputs.url == '' }} + env: + GH_TOKEN: ${{ secrets.GH_TOKEN }} + REPOSITORY: ${{ github.repository }} + run: | + pr_body="$(printf '%s\n\n%s' \ + 'Opens the production promotion path from staging to main. Merging this PR triggers semantic-release, then the existing production deployment workflow.' \ + 'Do not squash this PR. Use a merge commit to preserve the original conventional commits for semantic-release.')" + + pr_url="$(gh pr create \ + --repo "$REPOSITORY" \ + --base main \ + --head staging \ + --title "chore(release): promote staging to production" \ + --body "$pr_body")" + + echo "url=${pr_url}" >> "$GITHUB_OUTPUT" + echo "Created promotion PR: ${pr_url}" + + - name: Wait for required CI checks + if: ${{ inputs.auto_merge && steps.compare.outputs.ahead_by != '0' }} + env: + GH_TOKEN: ${{ secrets.GH_TOKEN }} + REPOSITORY: ${{ github.repository }} + EXISTING_PR_URL: ${{ steps.existing-pr.outputs.url }} + CREATED_PR_URL: ${{ steps.create-pr.outputs.url }} + run: | + set -euo pipefail + + pr_url="${EXISTING_PR_URL:-$CREATED_PR_URL}" + + for attempt in $(seq 1 60); do + set +e + checks="$(gh pr checks "$pr_url" \ + --repo "$REPOSITORY" \ + --json name,state,workflow \ + --jq '[.[] | select(((.name // "") | ascii_downcase | contains("codecov") | not) and ((.workflow // "") | ascii_downcase | contains("codecov") | not))]')" + checks_exit_code="$?" + set -e + + if [ "$checks_exit_code" != "0" ] && [ "$checks_exit_code" != "1" ] && [ "$checks_exit_code" != "8" ]; then + echo "Unable to read PR checks." + exit "$checks_exit_code" + fi + + total_count="$(echo "$checks" | jq 'length')" + blocking_count="$(echo "$checks" | jq '[.[] | select(.state == "FAILURE" or .state == "CANCELLED" or .state == "SKIPPED" or .state == "TIMED_OUT")] | length')" + pending_count="$(echo "$checks" | jq '[.[] | select(.state == "PENDING" or .state == "QUEUED" or .state == "IN_PROGRESS" or .state == "WAITING" or .state == "REQUESTED")] | length')" + + if [ "$blocking_count" != "0" ]; then + echo "A non-Codecov CI check failed. Production promotion is blocked." + echo "$checks" | jq -r '.[] | select(.state == "FAILURE" or .state == "CANCELLED" or .state == "SKIPPED" or .state == "TIMED_OUT") | "- \(.workflow): \(.name) [\(.state)]"' + exit 1 + fi + + if [ "$total_count" = "0" ]; then + echo "No non-Codecov CI checks found yet. Waiting before enabling auto-merge." + elif [ "$pending_count" = "0" ]; then + echo "All non-Codecov CI checks passed." + exit 0 + fi + + echo "Waiting for non-Codecov CI checks to finish. Attempt ${attempt}/60." + sleep 30 + done + + echo "Timed out waiting for non-Codecov CI checks." + exit 1 + + - name: Enable auto-merge + if: ${{ inputs.auto_merge && steps.compare.outputs.ahead_by != '0' }} + env: + GH_TOKEN: ${{ secrets.GH_TOKEN }} + REPOSITORY: ${{ github.repository }} + EXISTING_PR_URL: ${{ steps.existing-pr.outputs.url }} + CREATED_PR_URL: ${{ steps.create-pr.outputs.url }} + run: | + pr_url="${EXISTING_PR_URL:-$CREATED_PR_URL}" + + gh pr merge "$pr_url" \ + --repo "$REPOSITORY" \ + --auto \ + --merge + + echo "Auto-merge enabled for ${pr_url}" + + - name: Summary + env: + AHEAD_BY: ${{ steps.compare.outputs.ahead_by }} + EXISTING_PR_URL: ${{ steps.existing-pr.outputs.url }} + CREATED_PR_URL: ${{ steps.create-pr.outputs.url }} + run: | + if [ "$AHEAD_BY" = "0" ]; then + echo "staging is already aligned with main. Nothing to promote." >> "$GITHUB_STEP_SUMMARY" + exit 0 + fi + + pr_url="${EXISTING_PR_URL:-$CREATED_PR_URL}" + echo "Promotion PR: ${pr_url}" >> "$GITHUB_STEP_SUMMARY" From f19889e96ed796df4e126933f5a2cb9ea14f362d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9mi=20Bonnet?= Date: Tue, 30 Jun 2026 17:40:16 +0200 Subject: [PATCH 02/15] fix(release): use rebase for production promotion (#2789) --- .github/workflows/promote-production.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/promote-production.yml b/.github/workflows/promote-production.yml index e4520d8c222..5860ed67c93 100644 --- a/.github/workflows/promote-production.yml +++ b/.github/workflows/promote-production.yml @@ -68,7 +68,7 @@ jobs: run: | pr_body="$(printf '%s\n\n%s' \ 'Opens the production promotion path from staging to main. Merging this PR triggers semantic-release, then the existing production deployment workflow.' \ - 'Do not squash this PR. Use a merge commit to preserve the original conventional commits for semantic-release.')" + 'Do not squash this PR. Use rebase merge to preserve the original conventional commits for semantic-release.')" pr_url="$(gh pr create \ --repo "$REPOSITORY" \ @@ -143,7 +143,7 @@ jobs: gh pr merge "$pr_url" \ --repo "$REPOSITORY" \ --auto \ - --merge + --rebase echo "Auto-merge enabled for ${pr_url}" From 640bcb764a951db8e3204c1c2ed8b4a8db78c64f Mon Sep 17 00:00:00 2001 From: Romain Billard Date: Wed, 1 Jul 2026 15:06:14 +0200 Subject: [PATCH 03/15] feat(blueprints): creation flow improvements (#2787) * Add icons for blueprints * Improve blueprint-based service creation flow loading behavior This is done by listening to a new WS API endpoint * Reuse ServiceAvatar component * Remove aria-hidden wrappers * Refactor WS subscription and submit logic * Rename onInvalidateOperation to onQueryInvalidated * Update success toast display strategy * Add timeout fallback whem WS is not received --- libs/domains/services/feature/src/index.ts | 1 + .../blueprint-details-panel.spec.tsx | 3 +- .../blueprint-details-panel.tsx | 14 +- ...-blueprint-service-created-socket.spec.tsx | 100 +++++++++ .../use-blueprint-service-created-socket.ts | 45 ++++ .../use-create-blueprint.ts | 3 - .../blueprint-creation-flow.spec.tsx | 207 +++++++++++++++--- .../blueprint-step-summary.tsx | 172 ++++++++++++--- .../blueprint-card/blueprint-card.tsx | 9 +- .../src/lib/service-new/service-new.spec.tsx | 3 +- .../use-react-query-ws-subscription.spec.tsx | 5 +- .../use-react-query-ws-subscription.ts | 26 ++- 12 files changed, 512 insertions(+), 76 deletions(-) create mode 100644 libs/domains/services/feature/src/lib/hooks/use-blueprint-service-created-socket/use-blueprint-service-created-socket.spec.tsx create mode 100644 libs/domains/services/feature/src/lib/hooks/use-blueprint-service-created-socket/use-blueprint-service-created-socket.ts diff --git a/libs/domains/services/feature/src/index.ts b/libs/domains/services/feature/src/index.ts index 09c53f2d1ff..438cb9322fe 100644 --- a/libs/domains/services/feature/src/index.ts +++ b/libs/domains/services/feature/src/index.ts @@ -33,6 +33,7 @@ export * from './lib/hooks/use-argocd-manifest/use-argocd-manifest' 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-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' export * from './lib/hooks/use-delete-all-services/use-delete-all-services' diff --git a/libs/domains/services/feature/src/lib/blueprint-details-panel/blueprint-details-panel.spec.tsx b/libs/domains/services/feature/src/lib/blueprint-details-panel/blueprint-details-panel.spec.tsx index e3633bf3b58..f19c9f549b1 100644 --- a/libs/domains/services/feature/src/lib/blueprint-details-panel/blueprint-details-panel.spec.tsx +++ b/libs/domains/services/feature/src/lib/blueprint-details-panel/blueprint-details-panel.spec.tsx @@ -47,7 +47,7 @@ const blueprint: BlueprintItem = { name: 'AWS S3 Bucket', kind: 'ServiceBlueprint', description: 'Object storage with server-side encryption.', - icon: 'https://cdn.qovery.com/icons/s3.svg', + icon: 'app://qovery-console/s3', categories: ['storage'], provider: 'aws', serviceFamily: 's3', @@ -85,6 +85,7 @@ describe('BlueprintDetailsPanel', () => { const dialog = screen.getByRole('dialog', { name: 'AWS S3 Bucket' }) + expect(dialog.querySelector('img[src="/assets/devicon/s3.svg"]')).toBeInTheDocument() expect(within(dialog).getByText('Object storage with server-side encryption.')).toBeInTheDocument() expect(within(dialog).getByText('AWS')).toBeInTheDocument() expect(within(dialog).getByText('v1')).toBeInTheDocument() diff --git a/libs/domains/services/feature/src/lib/blueprint-details-panel/blueprint-details-panel.tsx b/libs/domains/services/feature/src/lib/blueprint-details-panel/blueprint-details-panel.tsx index 9458cc0ea74..387a062f3da 100644 --- a/libs/domains/services/feature/src/lib/blueprint-details-panel/blueprint-details-panel.tsx +++ b/libs/domains/services/feature/src/lib/blueprint-details-panel/blueprint-details-panel.tsx @@ -8,6 +8,7 @@ import { Badge, Button, ExternalLink, Icon, Link, Sheet } from '@qovery/shared/u import { twMerge } from '@qovery/shared/util-js' import { BlueprintQueryBoundary } from '../blueprint-query-boundary/blueprint-query-boundary' import { useBlueprintCatalogServiceReadme } from '../hooks/use-blueprint-catalog-service-readme/use-blueprint-catalog-service-readme' +import { ServiceAvatar } from '../service-avatar/service-avatar' function getBlueprintRepositoryName({ provider, serviceFamily }: BlueprintItem) { return `qovery-blueprints/${serviceFamily || provider}` @@ -169,8 +170,17 @@ function BlueprintDetailsPanelContent({
- - + + {blueprint.name} diff --git a/libs/domains/services/feature/src/lib/hooks/use-blueprint-service-created-socket/use-blueprint-service-created-socket.spec.tsx b/libs/domains/services/feature/src/lib/hooks/use-blueprint-service-created-socket/use-blueprint-service-created-socket.spec.tsx new file mode 100644 index 00000000000..ce0b0705d8b --- /dev/null +++ b/libs/domains/services/feature/src/lib/hooks/use-blueprint-service-created-socket/use-blueprint-service-created-socket.spec.tsx @@ -0,0 +1,100 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { renderHook } from '@testing-library/react' +import { type PropsWithChildren } from 'react' +import { queries, useReactQueryWsSubscription } from '@qovery/state/util-queries' +import { useBlueprintServiceCreatedSocket } from './use-blueprint-service-created-socket' + +jest.mock('@qovery/shared/util-node-env', () => ({ + QOVERY_WS: 'wss://ws.qovery.com', +})) + +jest.mock('@qovery/state/util-queries', () => ({ + ...jest.requireActual('@qovery/state/util-queries'), + useReactQueryWsSubscription: jest.fn(), +})) + +const useReactQueryWsSubscriptionMock = jest.mocked(useReactQueryWsSubscription) + +function createWrapper(queryClient: QueryClient) { + return function Wrapper({ children }: PropsWithChildren) { + return {children} + } +} + +function renderUseBlueprintServiceCreatedSocket( + props: Parameters[0], + queryClient = new QueryClient() +) { + renderHook(() => useBlueprintServiceCreatedSocket(props), { + wrapper: createWrapper(queryClient), + }) + + return queryClient +} + +describe('useBlueprintServiceCreatedSocket', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it('should subscribe to the blueprint service-created endpoint with route params', () => { + renderUseBlueprintServiceCreatedSocket({ + organizationId: 'org-1', + projectId: 'proj-1', + environmentId: 'env-1', + }) + + expect(useReactQueryWsSubscriptionMock).toHaveBeenCalledWith( + expect.objectContaining({ + url: 'wss://ws.qovery.com/blueprint/service-created', + urlSearchParams: { + organization: 'org-1', + project: 'proj-1', + environment: 'env-1', + }, + enabled: true, + }) + ) + }) + + it('should not subscribe until all params are available', () => { + renderUseBlueprintServiceCreatedSocket({ + organizationId: 'org-1', + projectId: 'proj-1', + enabled: true, + }) + + expect(useReactQueryWsSubscriptionMock).toHaveBeenCalledWith(expect.objectContaining({ enabled: false })) + }) + + it('should invalidate the environment service list when the service-created event is received', () => { + const queryClient = renderUseBlueprintServiceCreatedSocket({ + organizationId: 'org-1', + projectId: 'proj-1', + environmentId: 'env-1', + }) + const invalidateQueries = jest.spyOn(queryClient, 'invalidateQueries') + const subscriptionConfig = useReactQueryWsSubscriptionMock.mock.calls[0]?.[0] + + subscriptionConfig?.onMessage?.(queryClient, {}) + + expect(invalidateQueries).toHaveBeenCalledWith({ + queryKey: queries.services.list('env-1').queryKey, + }) + }) + + it('should notify the caller when a query invalidation event is processed', () => { + const onServiceCreated = jest.fn() + const queryClient = renderUseBlueprintServiceCreatedSocket({ + organizationId: 'org-1', + projectId: 'proj-1', + environmentId: 'env-1', + onServiceCreated, + }) + const subscriptionConfig = useReactQueryWsSubscriptionMock.mock.calls[0]?.[0] + + subscriptionConfig?.onQueryInvalidated?.(queryClient, { entity: ['services'], id: 'service-1' }) + + expect(onServiceCreated).toHaveBeenCalledTimes(1) + }) +}) diff --git a/libs/domains/services/feature/src/lib/hooks/use-blueprint-service-created-socket/use-blueprint-service-created-socket.ts b/libs/domains/services/feature/src/lib/hooks/use-blueprint-service-created-socket/use-blueprint-service-created-socket.ts new file mode 100644 index 00000000000..88876b1199b --- /dev/null +++ b/libs/domains/services/feature/src/lib/hooks/use-blueprint-service-created-socket/use-blueprint-service-created-socket.ts @@ -0,0 +1,45 @@ +import { useQueryClient } from '@tanstack/react-query' +import { useCallback } from 'react' +import { QOVERY_WS } from '@qovery/shared/util-node-env' +import { queries, useReactQueryWsSubscription } from '@qovery/state/util-queries' + +export interface UseBlueprintServiceCreatedSocketProps { + organizationId?: string + projectId?: string + environmentId?: string + enabled?: boolean + onServiceCreated?: () => void +} + +export function useBlueprintServiceCreatedSocket({ + organizationId, + projectId, + environmentId, + enabled = true, + onServiceCreated, +}: UseBlueprintServiceCreatedSocketProps) { + const queryClient = useQueryClient() + + const handleServiceCreated = useCallback(() => { + if (!environmentId) { + return + } + + queryClient.invalidateQueries({ + queryKey: queries.services.list(environmentId).queryKey, + }) + onServiceCreated?.() + }, [environmentId, onServiceCreated, queryClient]) + + useReactQueryWsSubscription({ + url: QOVERY_WS + '/blueprint/service-created', + urlSearchParams: { + organization: organizationId, + project: projectId, + environment: environmentId, + }, + enabled: enabled && Boolean(organizationId) && Boolean(projectId) && Boolean(environmentId), + onMessage: handleServiceCreated, + onQueryInvalidated: handleServiceCreated, + }) +} diff --git a/libs/domains/services/feature/src/lib/hooks/use-create-blueprint/use-create-blueprint.ts b/libs/domains/services/feature/src/lib/hooks/use-create-blueprint/use-create-blueprint.ts index ee964fc6fcf..eeb62611a24 100644 --- a/libs/domains/services/feature/src/lib/hooks/use-create-blueprint/use-create-blueprint.ts +++ b/libs/domains/services/feature/src/lib/hooks/use-create-blueprint/use-create-blueprint.ts @@ -12,9 +12,6 @@ export function useCreateBlueprint() { }) }, meta: { - notifyOnSuccess: { - title: 'Your service has been created', - }, notifyOnError: true, }, }) diff --git a/libs/domains/services/feature/src/lib/service-creation-flow/blueprint/blueprint-creation-flow.spec.tsx b/libs/domains/services/feature/src/lib/service-creation-flow/blueprint/blueprint-creation-flow.spec.tsx index 02dca4ea00b..f790615b34b 100644 --- a/libs/domains/services/feature/src/lib/service-creation-flow/blueprint/blueprint-creation-flow.spec.tsx +++ b/libs/domains/services/feature/src/lib/service-creation-flow/blueprint/blueprint-creation-flow.spec.tsx @@ -1,5 +1,5 @@ import { useParams } from '@tanstack/react-router' -import { within } from '@testing-library/react' +import { act, within } from '@testing-library/react' import type { BlueprintItem, BlueprintManifestResponseResultsInner } from 'qovery-typescript-axios' import { type ReactNode, useEffect, useState } from 'react' import { renderWithProviders, screen, waitFor } from '@qovery/shared/util-tests' @@ -14,7 +14,9 @@ const mockNavigate = jest.fn() const mockUseSearch = jest.fn() const mockUseBlueprintCatalogServiceManifest = jest.fn() const mockUseBlueprintCatalogServiceReadme = jest.fn() +const mockUseBlueprintServiceCreatedSocket = jest.fn() const mockCreateBlueprint = jest.fn() +const mockToast = jest.fn() jest.mock('@tanstack/react-router', () => ({ ...jest.requireActual('@tanstack/react-router'), @@ -27,6 +29,7 @@ jest.mock('@qovery/shared/ui', () => { const actual = jest.requireActual('@qovery/shared/ui') return { ...actual, + toast: (...args: Parameters) => mockToast(...args), Link: ({ children, to, ...props }: { children: ReactNode; to?: string; [key: string]: unknown }) => typeof to === 'string' ? ( @@ -46,6 +49,10 @@ jest.mock('../../hooks/use-blueprint-catalog-service-readme/use-blueprint-catalo useBlueprintCatalogServiceReadme: (props: unknown) => mockUseBlueprintCatalogServiceReadme(props), })) +jest.mock('../../hooks/use-blueprint-service-created-socket/use-blueprint-service-created-socket', () => ({ + useBlueprintServiceCreatedSocket: (props: unknown) => mockUseBlueprintServiceCreatedSocket(props), +})) + jest.mock('../../hooks/use-create-blueprint/use-create-blueprint', () => ({ useCreateBlueprint: () => ({ mutateAsync: mockCreateBlueprint, @@ -364,35 +371,175 @@ describe('BlueprintCreationFlow', () => { await screen.findByText(/custom-postgres/) await userEvent.click(screen.getByTestId('button-create-deploy')) - expect(mockCreateBlueprint).toHaveBeenCalledWith({ - environmentId: 'env-1', - deploy: true, - payload: { - name: 'custom-postgres', - tag: 'aws/postgres/17/1.0.0', - icon: 'https://cdn.qovery.com/icons/postgresql.svg', - variables: expect.arrayContaining([ - { - name: 'db_name', - value: 'production', - is_secret: false, - }, - { - name: 'db_username', - value: 'postgres', - is_secret: false, - }, - { - name: 'db_password', - value: 'super-secret', - is_secret: true, - }, - { - name: 'skip_final_snapshot', - value: 'true', - is_secret: false, - }, - ]), + await waitFor(() => { + expect(mockCreateBlueprint).toHaveBeenCalledWith({ + environmentId: 'env-1', + deploy: true, + payload: { + name: 'custom-postgres', + tag: 'aws/postgres/17/1.0.0', + icon: 'https://cdn.qovery.com/icons/postgresql.svg', + variables: expect.arrayContaining([ + { + name: 'db_name', + value: 'production', + is_secret: false, + }, + { + name: 'db_username', + value: 'postgres', + is_secret: false, + }, + { + name: 'db_password', + value: 'super-secret', + is_secret: true, + }, + { + name: 'skip_final_snapshot', + value: 'true', + is_secret: false, + }, + ]), + }, + }) + }) + }) + + it('should start listening for the blueprint service-created event before creating the blueprint', async () => { + jest.useFakeTimers() + let resolveCreateBlueprint: (value: { environment_id: string }) => void = jest.fn() + mockCreateBlueprint.mockImplementationOnce( + () => + new Promise<{ environment_id: string }>((resolve) => { + resolveCreateBlueprint = resolve + }) + ) + + const { userEvent } = renderBlueprintFlow( + + + + ) + + await screen.findByText(/custom-postgres/) + await userEvent.click(screen.getByTestId('button-create')) + + await waitFor(() => { + expect(mockCreateBlueprint).toHaveBeenCalled() + }) + + const enabledSocketCall = mockUseBlueprintServiceCreatedSocket.mock.calls.find( + ([props]) => + (props as { enabled?: boolean; organizationId?: string; projectId?: string; environmentId?: string }) + .enabled === true + ) + const enabledSocketCallIndex = mockUseBlueprintServiceCreatedSocket.mock.calls.findIndex( + ([props]) => + (props as { enabled?: boolean; organizationId?: string; projectId?: string; environmentId?: string }) + .enabled === true + ) + expect(enabledSocketCall?.[0]).toEqual( + expect.objectContaining({ + organizationId: 'org-1', + projectId: 'proj-1', + environmentId: 'env-1', + enabled: true, + }) + ) + expect(mockUseBlueprintServiceCreatedSocket.mock.invocationCallOrder[enabledSocketCallIndex]).toBeLessThan( + mockCreateBlueprint.mock.invocationCallOrder[0] + ) + expect(mockNavigate).not.toHaveBeenCalledWith({ + to: '/organization/$organizationId/project/$projectId/environment/$environmentId/overview', + params: { + organizationId: 'org-1', + projectId: 'proj-1', + environmentId: 'env-1', + }, + }) + + await act(async () => { + resolveCreateBlueprint({ environment_id: 'env-1' }) + }) + expect(mockToast).not.toHaveBeenCalled() + + act(() => { + jest.advanceTimersByTime(29_999) + }) + expect(mockToast).not.toHaveBeenCalled() + + const socketProps = mockUseBlueprintServiceCreatedSocket.mock.calls.at(-1)?.[0] as { + onServiceCreated: () => void + } + act(() => { + socketProps.onServiceCreated() + }) + + expect(mockToast).toHaveBeenCalledWith('success', 'Your service has been created') + expect(mockNavigate).toHaveBeenCalledWith({ + to: '/organization/$organizationId/project/$projectId/environment/$environmentId/overview', + params: { + organizationId: 'org-1', + projectId: 'proj-1', + environmentId: 'env-1', + }, + }) + + act(() => { + jest.advanceTimersByTime(1) + }) + expect(mockToast).toHaveBeenCalledTimes(1) + }) + + it('should complete the blueprint creation flow after a fallback timeout when the websocket event is missed', async () => { + jest.useFakeTimers() + let resolveCreateBlueprint: (value: { environment_id: string }) => void = jest.fn() + mockCreateBlueprint.mockImplementationOnce( + () => + new Promise<{ environment_id: string }>((resolve) => { + resolveCreateBlueprint = resolve + }) + ) + + const { userEvent } = renderBlueprintFlow( + + + + ) + + await screen.findByText(/custom-postgres/) + await userEvent.click(screen.getByTestId('button-create')) + + await waitFor(() => { + expect(mockCreateBlueprint).toHaveBeenCalled() + }) + + await act(async () => { + resolveCreateBlueprint({ environment_id: 'env-1' }) + }) + + expect(mockToast).not.toHaveBeenCalled() + expect(mockNavigate).not.toHaveBeenCalledWith({ + to: '/organization/$organizationId/project/$projectId/environment/$environmentId/overview', + params: { + organizationId: 'org-1', + projectId: 'proj-1', + environmentId: 'env-1', + }, + }) + + act(() => { + jest.advanceTimersByTime(30_000) + }) + + expect(mockToast).toHaveBeenCalledWith('success', 'Your service has been created') + expect(mockNavigate).toHaveBeenCalledWith({ + to: '/organization/$organizationId/project/$projectId/environment/$environmentId/overview', + params: { + organizationId: 'org-1', + projectId: 'proj-1', + environmentId: 'env-1', }, }) }) 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 22963be869f..8a5328247d3 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 @@ -1,8 +1,10 @@ import { useNavigate, useParams } from '@tanstack/react-router' import posthog from 'posthog-js' -import { useEffect, useState } from 'react' +import { type BlueprintCreateRequest } from 'qovery-typescript-axios' +import { useCallback, useEffect, useRef, useState } from 'react' import { type ServiceCreateSection } from '@qovery/shared/router' -import { Button, FunnelFlowBody, Heading, Icon, Section, SummaryValue } from '@qovery/shared/ui' +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 { @@ -12,6 +14,8 @@ import { isFieldValid, } from '../blueprint-creation-utils/blueprint-creation-utils' +const BLUEPRINT_SERVICE_CREATED_FALLBACK_TIMEOUT_MS = 30_000 + export function BlueprintStepSummary() { const navigate = useNavigate() const { organizationId = '', projectId = '', environmentId = '' } = useParams({ strict: false }) @@ -26,6 +30,14 @@ export function BlueprintStepSummary() { setCurrentStep, } = useBlueprintCreateContext() const [submitMode, setSubmitMode] = useState<'create' | 'create-and-deploy' | null>(null) + const [isWaitingForServiceCreated, setIsWaitingForServiceCreated] = useState(false) + const [pendingBlueprintCreation, setPendingBlueprintCreation] = useState<{ + deploy: boolean + payload: BlueprintCreateRequest + } | null>(null) + const hasHandledServiceCreatedRef = useRef(false) + const hasStartedBlueprintCreationRef = useRef(false) + const serviceCreatedFallbackTimeoutRef = useRef | null>(null) const { mutateAsync: createBlueprint } = useCreateBlueprint() const { fields, serviceName } = form.watch() const variableFields = [...requiredBlueprintFields, ...optionalBlueprintFields] @@ -37,10 +49,64 @@ export function BlueprintStepSummary() { navigate({ to: creationFlowUrl, search: { section } }) } + const navigateToEnvironmentOverview = useCallback(() => { + navigate({ + to: '/organization/$organizationId/project/$projectId/environment/$environmentId/overview', + params: { + organizationId, + projectId, + environmentId, + }, + }) + }, [environmentId, navigate, organizationId, projectId]) + + const clearServiceCreatedFallbackTimeout = useCallback(() => { + if (!serviceCreatedFallbackTimeoutRef.current) { + return + } + + clearTimeout(serviceCreatedFallbackTimeoutRef.current) + serviceCreatedFallbackTimeoutRef.current = null + }, []) + + const handleBlueprintServiceCreated = useCallback(() => { + if (hasHandledServiceCreatedRef.current) { + return + } + + hasHandledServiceCreatedRef.current = true + clearServiceCreatedFallbackTimeout() + setIsWaitingForServiceCreated(false) + setSubmitMode(null) + toast('success', 'Your service has been created') + navigateToEnvironmentOverview() + }, [clearServiceCreatedFallbackTimeout, navigateToEnvironmentOverview]) + + const startServiceCreatedFallbackTimeout = useCallback(() => { + if (hasHandledServiceCreatedRef.current) { + return + } + + clearServiceCreatedFallbackTimeout() + serviceCreatedFallbackTimeoutRef.current = setTimeout(() => { + handleBlueprintServiceCreated() + }, BLUEPRINT_SERVICE_CREATED_FALLBACK_TIMEOUT_MS) + }, [clearServiceCreatedFallbackTimeout, handleBlueprintServiceCreated]) + + useBlueprintServiceCreatedSocket({ + organizationId, + projectId, + environmentId, + enabled: isWaitingForServiceCreated, + onServiceCreated: handleBlueprintServiceCreated, + }) + useEffect(() => { setCurrentStep(2) }, [setCurrentStep]) + useEffect(() => () => clearServiceCreatedFallbackTimeout(), [clearServiceCreatedFallbackTimeout]) + useEffect(() => { if (!serviceName.trim() || !isBlueprintSetupValid) { navigate({ to: creationFlowUrl }) @@ -48,40 +114,80 @@ export function BlueprintStepSummary() { } }, [creationFlowUrl, isBlueprintSetupValid, navigate, serviceName]) - const handleSubmit = async (withDeploy: boolean) => { - setSubmitMode(withDeploy ? 'create-and-deploy' : 'create') - const formValues = form.getValues() + useEffect(() => { + if (!pendingBlueprintCreation || !isWaitingForServiceCreated || hasStartedBlueprintCreationRef.current) { + return + } - try { - await createBlueprint({ - environmentId, - deploy: withDeploy, - payload: { - name: formValues.serviceName, - tag: blueprint.majorVersions[0]?.latestTag ?? '', - icon: blueprint.icon, - variables: buildBlueprintVariables(formValues.fields, blueprintFields), - }, - }) - - posthog.capture('create-service', { - selectedServiceType: 'blueprint', - selectedServiceSubType: blueprint.serviceFamily ?? blueprint.provider, - }) - - navigate({ - to: '/organization/$organizationId/project/$projectId/environment/$environmentId/overview', - params: { - organizationId, - projectId, + let shouldUpdateState = true + const blueprintCreation = pendingBlueprintCreation + hasStartedBlueprintCreationRef.current = true + + async function createPendingBlueprint() { + try { + await createBlueprint({ environmentId, - }, - }) - } catch { - // errors are surfaced by mutation notifications - } finally { - setSubmitMode(null) + deploy: blueprintCreation.deploy, + payload: blueprintCreation.payload, + }) + + if (!shouldUpdateState) { + return + } + + posthog.capture('create-service', { + selectedServiceType: 'blueprint', + selectedServiceSubType: blueprint.serviceFamily ?? blueprint.provider, + }) + setPendingBlueprintCreation(null) + startServiceCreatedFallbackTimeout() + } catch { + if (!shouldUpdateState) { + return + } + + // errors are surfaced by mutation notifications + clearServiceCreatedFallbackTimeout() + hasStartedBlueprintCreationRef.current = false + setPendingBlueprintCreation(null) + setIsWaitingForServiceCreated(false) + setSubmitMode(null) + } + } + + createPendingBlueprint() + + return () => { + shouldUpdateState = false } + }, [ + blueprint.provider, + blueprint.serviceFamily, + clearServiceCreatedFallbackTimeout, + createBlueprint, + environmentId, + isWaitingForServiceCreated, + pendingBlueprintCreation, + startServiceCreatedFallbackTimeout, + ]) + + const handleSubmit = (withDeploy: boolean) => { + const formValues = form.getValues() + + setSubmitMode(withDeploy ? 'create-and-deploy' : 'create') + clearServiceCreatedFallbackTimeout() + hasHandledServiceCreatedRef.current = false + hasStartedBlueprintCreationRef.current = false + setIsWaitingForServiceCreated(true) + setPendingBlueprintCreation({ + deploy: withDeploy, + payload: { + name: formValues.serviceName, + tag: blueprint.majorVersions[0]?.latestTag ?? '', + icon: blueprint.icon, + variables: buildBlueprintVariables(formValues.fields, blueprintFields), + }, + }) } return ( diff --git a/libs/domains/services/feature/src/lib/service-new/blueprint-card/blueprint-card.tsx b/libs/domains/services/feature/src/lib/service-new/blueprint-card/blueprint-card.tsx index 06e9514a35b..2667dd1d94e 100644 --- a/libs/domains/services/feature/src/lib/service-new/blueprint-card/blueprint-card.tsx +++ b/libs/domains/services/feature/src/lib/service-new/blueprint-card/blueprint-card.tsx @@ -1,6 +1,7 @@ import { useParams } from '@tanstack/react-router' import { type BlueprintItem } from 'qovery-typescript-axios' import { Button, Link } from '@qovery/shared/ui' +import { ServiceAvatar } from '../../service-avatar/service-avatar' export function BlueprintCard({ blueprint, @@ -14,7 +15,13 @@ export function BlueprintCard({ return (
- +

{blueprint.name}

{blueprint.description}

diff --git a/libs/domains/services/feature/src/lib/service-new/service-new.spec.tsx b/libs/domains/services/feature/src/lib/service-new/service-new.spec.tsx index 6d8fd42d2c3..dc625b2e853 100644 --- a/libs/domains/services/feature/src/lib/service-new/service-new.spec.tsx +++ b/libs/domains/services/feature/src/lib/service-new/service-new.spec.tsx @@ -76,7 +76,7 @@ const blueprints: BlueprintItem[] = [ name: 'AWS S3 Bucket', kind: 'ServiceBlueprint', description: 'Object storage with server-side encryption, versioning, and configurable lifecycle policies.', - icon: 'https://cdn.qovery.com/icons/s3.svg', + icon: 'app://qovery-console/s3', categories: ['storage'], provider: 'aws', serviceFamily: 's3', @@ -158,6 +158,7 @@ describe('ServiceNew', () => { const blueprintsSectionScreen = within(blueprintsSection as HTMLElement) expect(blueprintsSectionScreen.getByText('AWS S3 Bucket')).toBeInTheDocument() expect(blueprintsSectionScreen.getByText('Redis')).toBeInTheDocument() + expect(blueprintsSection?.querySelector('img[src="/assets/devicon/s3.svg"]')).toBeInTheDocument() const deployLinks = blueprintsSectionScreen.getAllByRole('link', { name: 'Deploy' }) expect(deployLinks).toHaveLength(2) expect(deployLinks[0]).toHaveAttribute( 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 3aba7d6dada..50e220f3020 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 @@ -24,6 +24,7 @@ describe('useReactQueryWsSubscription', () => { let server: WS beforeEach(() => { + jest.clearAllMocks() server = new WS('ws://localhost:1234', { jsonProtocol: true }) }) @@ -71,8 +72,9 @@ describe('useReactQueryWsSubscription', () => { }) it('should invalidate query keys on invalidate operation message', async () => { + const onQueryInvalidated = jest.fn() const { unmount } = renderHook(() => - useReactQueryWsSubscription({ url: 'ws://localhost:1234', onMessage: jest.fn() }) + useReactQueryWsSubscription({ url: 'ws://localhost:1234', onMessage: jest.fn(), onQueryInvalidated }) ) const queryClient = useQueryClient() const connection = await server.connected @@ -80,6 +82,7 @@ describe('useReactQueryWsSubscription', () => { server.send({ entity: ['projects', 'list'] }) expect(queryClient.invalidateQueries).toHaveBeenNthCalledWith(1, { queryKey: ['projects', 'list'] }) + expect(onQueryInvalidated).toHaveBeenCalledWith(queryClient, { entity: ['projects', 'list'] }) connection.close() unmount() }) 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 e4543794365..9c7d1b96f99 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 @@ -13,6 +13,7 @@ export interface UseReactQueryWsSubscriptionProps { /** WebSocket onmessage will be automatically handled if they are aligned with the expected format (https://tkdodo.eu/blog/using-web-sockets-with-react-query#consuming-data) otherwise you should provide an handler */ // eslint-disable-next-line @typescript-eslint/no-explicit-any onMessage?: (queryClient: QueryClient, data: any) => void + onQueryInvalidated?: (queryClient: QueryClient, data: InvalidateOperation) => void onOpen?: (queryClient: QueryClient, event: Event) => void onError?: (queryClient: QueryClient, event: Event) => void onClose?: (QueryClient: QueryClient, event: CloseEvent) => void @@ -36,6 +37,7 @@ export function useReactQueryWsSubscription({ url, urlSearchParams, onMessage, + onQueryInvalidated, onOpen, onError, onClose, @@ -62,6 +64,7 @@ export function useReactQueryWsSubscription({ } const searchParams = new URLSearchParams(_urlSearchParams) + const searchParamsString = searchParams.toString() const reconnectCount = useRef(0) useEffect(() => { @@ -70,6 +73,7 @@ export function useReactQueryWsSubscription({ } let timeout: ReturnType | undefined const controller = new AbortController() + let canReconnect = shouldReconnect async function connect({ signal }: { signal: AbortSignal }) { const token = await getAccessTokenSilently() @@ -77,7 +81,7 @@ export function useReactQueryWsSubscription({ // signal already aborted do nothing return } - const websocket = new WebSocket(`${url}?${searchParams.toString()}`, ['v1', 'auth.bearer.' + token]) + const websocket = new WebSocket(`${url}?${searchParamsString}`, ['v1', 'auth.bearer.' + token]) websocket.onopen = async (event) => { onOpen?.(queryClient, event) @@ -88,6 +92,7 @@ export function useReactQueryWsSubscription({ if (isInvalidateOperation(data)) { const queryKey = [...data.entity, data.id].filter(Boolean) queryClient.invalidateQueries({ queryKey }) + onQueryInvalidated?.(queryClient, data) } else { // XXX: Don't know how to handle it, let the caller handle it onMessage?.(queryClient, data) @@ -97,7 +102,7 @@ export function useReactQueryWsSubscription({ onError?.(queryClient, event) } websocket.onclose = async (event) => { - if (shouldReconnect && reconnectCount.current < MAX_RECONNECT_ATTEMPTS) { + if (canReconnect && reconnectCount.current < MAX_RECONNECT_ATTEMPTS) { timeout = setTimeout( function () { reconnectCount.current++ @@ -113,7 +118,7 @@ export function useReactQueryWsSubscription({ } const onAbort = () => { - shouldReconnect = false + canReconnect = false websocket.close() if (timeout) { clearTimeout(timeout) @@ -128,7 +133,20 @@ export function useReactQueryWsSubscription({ return () => { controller.abort() } - }, [queryClient, getAccessTokenSilently, onOpen, onMessage, onClose, url, searchParams.toString(), enabled]) + }, [ + queryClient, + getAccessTokenSilently, + onOpen, + onMessage, + onQueryInvalidated, + onError, + onClose, + shouldReconnect, + MAX_RECONNECT_ATTEMPTS, + url, + searchParamsString, + enabled, + ]) } export default useReactQueryWsSubscription From 752065d809b31113eddf35c50d484f244d412510 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9mi=20Bonnet?= Date: Wed, 1 Jul 2026 15:32:40 +0200 Subject: [PATCH 04/15] fix(release): production promotion rebase automerge (#2790) * fix(release): use rebase for production promotion * fix(release): allow skipped promotion checks * fix(release): require staging smoke check --- .github/workflows/promote-production.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/promote-production.yml b/.github/workflows/promote-production.yml index 5860ed67c93..c15ad26b30b 100644 --- a/.github/workflows/promote-production.yml +++ b/.github/workflows/promote-production.yml @@ -96,8 +96,8 @@ jobs: set +e checks="$(gh pr checks "$pr_url" \ --repo "$REPOSITORY" \ - --json name,state,workflow \ - --jq '[.[] | select(((.name // "") | ascii_downcase | contains("codecov") | not) and ((.workflow // "") | ascii_downcase | contains("codecov") | not))]')" + --json event,name,state,workflow \ + --jq '[.[] | select((.event == "pull_request" or (.event == "push" and ((.name // "") | ascii_downcase | contains("staging smoke test")))) and ((.name // "") | ascii_downcase | contains("codecov") | not) and ((.workflow // "") | ascii_downcase | contains("codecov") | not))]')" checks_exit_code="$?" set -e @@ -107,12 +107,12 @@ jobs: fi total_count="$(echo "$checks" | jq 'length')" - blocking_count="$(echo "$checks" | jq '[.[] | select(.state == "FAILURE" or .state == "CANCELLED" or .state == "SKIPPED" or .state == "TIMED_OUT")] | length')" + blocking_count="$(echo "$checks" | jq '[.[] | select(.state == "FAILURE" or .state == "CANCELLED" or .state == "TIMED_OUT")] | length')" pending_count="$(echo "$checks" | jq '[.[] | select(.state == "PENDING" or .state == "QUEUED" or .state == "IN_PROGRESS" or .state == "WAITING" or .state == "REQUESTED")] | length')" if [ "$blocking_count" != "0" ]; then - echo "A non-Codecov CI check failed. Production promotion is blocked." - echo "$checks" | jq -r '.[] | select(.state == "FAILURE" or .state == "CANCELLED" or .state == "SKIPPED" or .state == "TIMED_OUT") | "- \(.workflow): \(.name) [\(.state)]"' + echo "A non-Codecov CI check failed, was cancelled, or timed out. Production promotion is blocked." + echo "$checks" | jq -r '.[] | select(.state == "FAILURE" or .state == "CANCELLED" or .state == "TIMED_OUT") | "- \(.workflow): \(.name) [\(.state)]"' exit 1 fi From 54fea81006c99899ba3bc9f0b29da4cbc95d06e0 Mon Sep 17 00:00:00 2001 From: Romain Billard Date: Thu, 2 Jul 2026 17:42:59 +0200 Subject: [PATCH 05/15] fix(cluster): correct override issue when updating Karpenter config (#2792) * fix(cluster): correct override issue when updating Karpenter config * Bring back fallback values * Fix lint issue * Update unit test --- .../cluster-resources-settings.spec.tsx | 122 +++++++++++++++++- .../cluster-resources-settings.tsx | 5 + 2 files changed, 124 insertions(+), 3 deletions(-) diff --git a/libs/domains/clusters/feature/src/lib/cluster-resources-settings/cluster-resources-settings.spec.tsx b/libs/domains/clusters/feature/src/lib/cluster-resources-settings/cluster-resources-settings.spec.tsx index 06aa788653b..4bfe042fc64 100644 --- a/libs/domains/clusters/feature/src/lib/cluster-resources-settings/cluster-resources-settings.spec.tsx +++ b/libs/domains/clusters/feature/src/lib/cluster-resources-settings/cluster-resources-settings.spec.tsx @@ -1,9 +1,14 @@ import { wrapWithReactHookForm } from '__tests__/utils/wrap-with-react-hook-form' -import { CloudProviderEnum } from 'qovery-typescript-axios' +import { CloudProviderEnum, type Cluster } from 'qovery-typescript-axios' +import { act } from 'react' +import { useFormContext } from 'react-hook-form' import { type ClusterResourcesData } from '@qovery/shared/interfaces' import { renderWithProviders, screen } from '@qovery/shared/util-tests' import { ClusterResourcesSettings } from './cluster-resources-settings' +const mockOpenModal = jest.fn() +const mockCloseModal = jest.fn() + jest.mock('@qovery/domains/cloud-providers/feature', () => ({ ...jest.requireActual('@qovery/domains/cloud-providers/feature'), useCloudProviderInstanceTypes: () => ({ @@ -24,14 +29,16 @@ jest.mock('../gpu-resources-settings/gpu-resources-settings', () => ({ })) jest.mock('../nodepools-resources-settings/nodepools-resources-settings', () => ({ + __esModule: true, + default: () =>
Nodepools Settings
, NodepoolsResourcesSettings: () =>
Nodepools Settings
, })) jest.mock('@qovery/shared/ui', () => ({ ...jest.requireActual('@qovery/shared/ui'), useModal: () => ({ - openModal: jest.fn(), - closeModal: jest.fn(), + openModal: mockOpenModal, + closeModal: mockCloseModal, }), })) @@ -42,9 +49,28 @@ const defaultValues: ClusterResourcesData = { nodes: [3, 10], } +const cluster = { + region: 'us-east-1', + features: [ + { + id: 'KARPENTER', + value: { + default_service_architecture: 'AMD64', + }, + }, + ], +} as Cluster + +function FormValuesProbe() { + const { watch } = useFormContext() + + return
{JSON.stringify(watch('karpenter'))}
+} + describe('ClusterResourcesSettings', () => { beforeEach(() => { jest.clearAllMocks() + window.scrollTo = jest.fn() }) it('should render successfully', () => { @@ -147,6 +173,96 @@ describe('ClusterResourcesSettings', () => { expect(screen.getByText('Enable GPU nodepools')).toBeInTheDocument() }) + it('should preserve nodepool overrides when changing Karpenter instance types scope', () => { + const stableOverride = { + limits: { + enabled: false, + max_cpu_in_vcpu: 6, + max_memory_in_gibibytes: 10, + }, + consolidation: { + enabled: true, + days: ['SUNDAY'], + start_time: 'PT10:00', + duration: 'PT2H', + }, + consolidate_after: '30s', + } + const defaultOverride = { + limits: { + enabled: false, + max_cpu_in_vcpu: 6, + max_memory_in_gibibytes: 10, + }, + consolidate_after: '30m', + } + + renderWithProviders( + wrapWithReactHookForm( + <> + + + , + { + defaultValues: { + ...defaultValues, + karpenter: { + enabled: true, + default_service_architecture: 'AMD64', + qovery_node_pools: { + requirements: [{ key: 'InstanceSize', operator: 'In', values: ['2xlarge'] }], + stable_override: stableOverride, + default_override: defaultOverride, + }, + }, + }, + } + ) + ) + + act(() => { + screen.getByRole('button', { name: /edit/i }).click() + }) + + const modalContent = mockOpenModal.mock.calls[0][0].content as { + props: { + onChange: (values: { + default_service_architecture: 'ARM64' + qovery_node_pools: { + requirements: Array<{ key: string; operator: string; values: string[] }> + } + }) => void + } + } + + act(() => { + modalContent.props.onChange({ + default_service_architecture: 'ARM64', + qovery_node_pools: { + requirements: [{ key: 'InstanceSize', operator: 'In', values: ['4xlarge'] }], + }, + }) + }) + + expect(JSON.parse(screen.getByTestId('karpenter-values').textContent ?? '{}')).toEqual( + expect.objectContaining({ + spot_enabled: false, + disk_size_in_gib: 50, + default_service_architecture: 'ARM64', + qovery_node_pools: { + requirements: [{ key: 'InstanceSize', operator: 'In', values: ['4xlarge'] }], + stable_override: stableOverride, + default_override: defaultOverride, + }, + }) + ) + }) + it('should not render Karpenter section for non-AWS providers', () => { renderWithProviders( wrapWithReactHookForm( diff --git a/libs/domains/clusters/feature/src/lib/cluster-resources-settings/cluster-resources-settings.tsx b/libs/domains/clusters/feature/src/lib/cluster-resources-settings/cluster-resources-settings.tsx index c9f92a4ad0d..1107a5a833d 100644 --- a/libs/domains/clusters/feature/src/lib/cluster-resources-settings/cluster-resources-settings.tsx +++ b/libs/domains/clusters/feature/src/lib/cluster-resources-settings/cluster-resources-settings.tsx @@ -319,10 +319,15 @@ export function ClusterResourcesSettings(props: ClusterResourcesSettingsProps) { onClose={closeModal} onChange={(values) => { setValue('karpenter', { + ...(watchKarpenter ?? {}), enabled: watchKarpenterEnabled, spot_enabled: watchKarpenter?.spot_enabled ?? false, disk_size_in_gib: watchKarpenter?.disk_size_in_gib ?? 50, ...values, + qovery_node_pools: { + ...(watchKarpenter?.qovery_node_pools ?? {}), + ...values.qovery_node_pools, + }, }) }} /> From 042b859dd0e00a9c04e512be7b3949dfe5a9a0bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9mi=20Bonnet?= Date: Fri, 3 Jul 2026 08:28:44 +0200 Subject: [PATCH 06/15] fix(section-secret-manager-highlight): remove buggy Firefox animation (#2793) fix(organization): optimize secret manager marquee animation --- .../section-secret-manager-highlight.tsx | 8 +++--- libs/shared/util-js/src/index.ts | 1 + .../src/lib/is-firefox-browser.spec.ts | 25 +++++++++++++++++++ .../util-js/src/lib/is-firefox-browser.ts | 5 ++++ 4 files changed, 36 insertions(+), 3 deletions(-) create mode 100644 libs/shared/util-js/src/lib/is-firefox-browser.spec.ts create mode 100644 libs/shared/util-js/src/lib/is-firefox-browser.ts diff --git a/libs/domains/organizations/feature/src/lib/organization-overview/section-secret-manager-highlight/section-secret-manager-highlight.tsx b/libs/domains/organizations/feature/src/lib/organization-overview/section-secret-manager-highlight/section-secret-manager-highlight.tsx index b2289015a73..847e7dc890e 100644 --- a/libs/domains/organizations/feature/src/lib/organization-overview/section-secret-manager-highlight/section-secret-manager-highlight.tsx +++ b/libs/domains/organizations/feature/src/lib/organization-overview/section-secret-manager-highlight/section-secret-manager-highlight.tsx @@ -5,6 +5,7 @@ import { useMemo } from 'react' import { useClusters } from '@qovery/domains/clusters/feature' import { Button, Heading, Icon, Link, Section } from '@qovery/shared/ui' import { useLocalStorage, useSupportChat } from '@qovery/shared/util-hooks' +import { isFirefoxBrowser } from '@qovery/shared/util-js' import useOrganization from '../../hooks/use-organization/use-organization' const SECRET_KEYS = [ @@ -38,6 +39,7 @@ export function SectionSecretManagerHighlight() { return clusters.find((cluster) => cluster)?.id }, [clusters]) const isSecretManagerAvailable = canUseSecretManager(organization?.plan) + const shouldDisableAnimation = isFirefoxBrowser() if (!isVisible || !getSecretManagerClusterId) { return null @@ -109,12 +111,12 @@ export function SectionSecretManagerHighlight() { {SECRET_KEYS.map((keys, index) => ( diff --git a/libs/shared/util-js/src/index.ts b/libs/shared/util-js/src/index.ts index f51e3b62dfa..0c48d3e8f10 100644 --- a/libs/shared/util-js/src/index.ts +++ b/libs/shared/util-js/src/index.ts @@ -28,3 +28,4 @@ export * from './lib/format-healthcheck-probe' export * from './lib/prepare-variable-import-request' export * from './lib/gcp-wif-validation' export * from './lib/log-selection' +export * from './lib/is-firefox-browser' diff --git a/libs/shared/util-js/src/lib/is-firefox-browser.spec.ts b/libs/shared/util-js/src/lib/is-firefox-browser.spec.ts new file mode 100644 index 00000000000..2c544b17816 --- /dev/null +++ b/libs/shared/util-js/src/lib/is-firefox-browser.spec.ts @@ -0,0 +1,25 @@ +import { isFirefoxBrowser } from './is-firefox-browser' + +describe('isFirefoxBrowser', () => { + it('returns true for Firefox user agents', () => { + expect( + isFirefoxBrowser('Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:140.0) Gecko/20100101 Firefox/140.0') + ).toBe(true) + }) + + it('returns false for Dia user agents', () => { + expect( + isFirefoxBrowser( + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Dia/1.0 Firefox/140.0 Safari/537.36' + ) + ).toBe(false) + }) + + it('returns false for Chromium-based user agents', () => { + expect( + isFirefoxBrowser( + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36' + ) + ).toBe(false) + }) +}) diff --git a/libs/shared/util-js/src/lib/is-firefox-browser.ts b/libs/shared/util-js/src/lib/is-firefox-browser.ts new file mode 100644 index 00000000000..3cbd323ac15 --- /dev/null +++ b/libs/shared/util-js/src/lib/is-firefox-browser.ts @@ -0,0 +1,5 @@ +export function isFirefoxBrowser(userAgent = typeof navigator === 'undefined' ? '' : navigator.userAgent): boolean { + const normalizedUserAgent = userAgent.toLowerCase() + + return normalizedUserAgent.includes('firefox/') && !/(chrome|chromium|crios|edg|safari|dia)/.test(normalizedUserAgent) +} From 884df5ed021d83479230223886f5fbbdcb256ddf Mon Sep 17 00:00:00 2001 From: Fabien Fleureau Date: Fri, 3 Jul 2026 14:58:47 +0200 Subject: [PATCH 07/15] fix(services): rename CPU architecture "Default" option to "Any" (#2794) --- .../application-settings-resources.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libs/domains/services/feature/src/lib/application-settings-resources/application-settings-resources.tsx b/libs/domains/services/feature/src/lib/application-settings-resources/application-settings-resources.tsx index 6fee521bfa7..7647946af3e 100644 --- a/libs/domains/services/feature/src/lib/application-settings-resources/application-settings-resources.tsx +++ b/libs/domains/services/feature/src/lib/application-settings-resources/application-settings-resources.tsx @@ -26,7 +26,7 @@ import { FixedInstancesMode } from './fixed-instances-mode' import { HpaAutoscalingMode } from './hpa-autoscaling-mode' const CPU_ARCHITECTURE_OPTIONS = [ - { label: 'Default', value: 'DEFAULT' }, + { label: 'Any', value: 'DEFAULT' }, { label: 'ARM64', value: 'ARM64' }, { label: 'AMD64', value: 'AMD64' }, ] From c9b1f7873d64ce19b5d9acbcf63be4ba93baeb8f Mon Sep 17 00:00:00 2001 From: Julien Dan <41013692+jul-dan@users.noreply.github.com> Date: Fri, 3 Jul 2026 15:34:52 +0200 Subject: [PATCH 08/15] fix(section-secret-manager-highlight): use request-access-secrets-manager pylon form id (#2796) Co-authored-by: Claude Sonnet 5 --- .../section-secret-manager-highlight.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libs/domains/organizations/feature/src/lib/organization-overview/section-secret-manager-highlight/section-secret-manager-highlight.tsx b/libs/domains/organizations/feature/src/lib/organization-overview/section-secret-manager-highlight/section-secret-manager-highlight.tsx index 847e7dc890e..8ab45985358 100644 --- a/libs/domains/organizations/feature/src/lib/organization-overview/section-secret-manager-highlight/section-secret-manager-highlight.tsx +++ b/libs/domains/organizations/feature/src/lib/organization-overview/section-secret-manager-highlight/section-secret-manager-highlight.tsx @@ -101,7 +101,7 @@ export function SectionSecretManagerHighlight() { variant="solid" size="lg" className="absolute bottom-3 z-10 w-[calc(100%-24px)] justify-center" - onClick={() => showPylonForm('request-upgrade-plan')} + onClick={() => showPylonForm('request-access-secrets-manager')} > Contact us From 7fa0b36b88a00f8307dbc4f69010268be931cdd2 Mon Sep 17 00:00:00 2001 From: Julien Dan <41013692+jul-dan@users.noreply.github.com> Date: Fri, 3 Jul 2026 16:58:58 +0200 Subject: [PATCH 09/15] feat(onboarding): add a personalize onboarding section (#2781) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(onboarding): add getting started checklist on organization overview - Add SectionOnboarding component with 4-step checklist (workspace, cluster, environment, service) - Fetch onboarding status from new GET /organization/{id}/onboarding endpoint - Persist dismiss via POST /organization/{id}/onboarding with status DISMISSED - Hide section for existing orgs (null status) and invited members - Show animated deploying/failed states with cluster and service log links - Add McpSuggestionCard when no cluster exists - Add dev-only reset button to clear dismissed state - Simplify section-production-health (cluster creation moved to onboarding) Co-Authored-By: Claude Sonnet 4.6 * feat(onboarding): add COMPLETED status when all steps are done Co-Authored-By: Claude Sonnet 4.6 * feat(qov-1844): update onboarding hooks to use generated axios client Co-Authored-By: Claude Sonnet 4.6 * fix(onboarding): use is_deployed for cluster step and service_deployment_status for service step Co-Authored-By: Claude Sonnet 4.6 * fix(onboarding): correct cluster/service running status checks - Cluster: require both status===DEPLOYED and is_deployed to avoid showing as done when cluster is unavailable (stale status field) - Service: use state===DEPLOYED for running check; add isServiceStopped visual state showing "Service stopped" link when no service is running - Remove unused ServiceDeploymentStatusEnum import Co-Authored-By: Claude Sonnet 4.6 * fix(onboarding): show MCP skills card until all steps are completed Co-Authored-By: Claude Sonnet 4.6 * fix(onboarding): hide section for orgs without use_cases (old orgs) Old orgs have no onboarding record or use_cases=null — only show the section when use_cases is set (new orgs that went through the wizard). Co-Authored-By: Claude Sonnet 4.6 * fix(onboarding): redirect to deployment logs on service failure Co-Authored-By: Claude Sonnet 4.6 * fix(onboarding): show completion links based on selected use cases Preview environments link only if ephemeral-environments was selected, AI Builder Portal only if rde was selected. Invite teammates always shown. Co-Authored-By: Claude Sonnet 4.6 * chore(onboarding): remove dev-only reset dismiss button Co-Authored-By: Claude Sonnet 4.6 * chore: revert latest.json to staging * style(onboarding): apply prettier formatting Co-Authored-By: Claude Sonnet 4.6 * fix(onboarding): remove unused variables flagged by lint Co-Authored-By: Claude Sonnet 4.6 * test(onboarding): add tests for onboarding query and mutation Co-Authored-By: Claude Sonnet 4.6 * style(onboarding): apply prettier formatting to spec file Co-Authored-By: Claude Sonnet 4.6 * fix(tests): update section-production-health tests after cluster options moved to onboarding Cluster option cards (Qovery managed, BYOC, Local machine) and MCP skills card were moved to SectionOnboarding. Tests now reflect the simplified EmptyState with a single "Create cluster" CTA. Co-Authored-By: Claude Sonnet 4.6 * feat(onboarding): update checklist completion modal * feat(onboarding): enhance section onboarding with completion handling and navigation * feat(onboarding): add CompletionPartyHornIllustration component and integrate into section onboarding * fix(on-boarding): Avoid unnecessary calls when the onboarding is done Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * refactor(onboarding): address PR review comments - Remove dead code: use-rde-access.ts, use-update-user-signup.ts - Extract CompletionPartyHornIllustration to its own file (~200 lines removed from main) - Replace organizationId prop with useParams() in SectionOnboarding - Add enabled: !!firstEnvironment?.id to useDeploymentRule to prevent empty queries - Add enabled prop to UseDeploymentRuleProps - Memoize computed values in SectionOnboarding (useMemo on status flags, allServiceStatuses, completedCount) - Restore user?.communication_email ?? userToken?.email fallback in UserMenu - Extract duplicated navigate/onSuccess logic into handleResult() in CreateCloneEnvironmentModal Co-Authored-By: Claude Sonnet 4.6 * fix(onboarding): restore full refactoring lost during rebase conflict resolution - Remove SectionOnboardingProps, use useParams instead of organizationId prop - Add useMemo for all computed status flags and derived values - Add isOnboardingActive guard to prevent unnecessary API calls - Add enabled: !!firstEnvironment?.id to useDeploymentRule Co-Authored-By: Claude Sonnet 4.6 * refactor(auth): remove queryClient.clear() on logout Shared-device scenarios are not a supported use case. Co-Authored-By: Claude Sonnet 4.6 --------- Co-authored-by: Claude Sonnet 4.6 Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: Théo Grandin Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../completion-party-horn-illustration.tsx | 4395 +++++++++++++++++ .../section-onboarding/section-onboarding.tsx | 697 +++ .../use-organization-onboarding.ts | 24 + .../organization/$organizationId/overview.tsx | 2 + .../section-production-health.spec.tsx | 87 +- .../section-production-health.tsx | 269 +- .../create-clone-environment-modal.tsx | 21 +- .../use-deployment-rule.ts | 4 +- .../domains-organizations-data-access.spec.ts | 41 + .../lib/domains-organizations-data-access.ts | 18 + package.json | 2 +- yarn.lock | 10 +- 12 files changed, 5233 insertions(+), 337 deletions(-) create mode 100644 apps/console/src/app/components/section-onboarding/completion-party-horn-illustration.tsx create mode 100644 apps/console/src/app/components/section-onboarding/section-onboarding.tsx create mode 100644 apps/console/src/app/components/section-onboarding/use-organization-onboarding.ts create mode 100644 libs/domains/organizations/data-access/src/lib/domains-organizations-data-access.spec.ts diff --git a/apps/console/src/app/components/section-onboarding/completion-party-horn-illustration.tsx b/apps/console/src/app/components/section-onboarding/completion-party-horn-illustration.tsx new file mode 100644 index 00000000000..9004aa60961 --- /dev/null +++ b/apps/console/src/app/components/section-onboarding/completion-party-horn-illustration.tsx @@ -0,0 +1,4395 @@ +import { type SVGAttributes } from 'react' +import { twMerge } from '@qovery/shared/util-js' + +export function CompletionPartyHornIllustration({ className, ...props }: SVGAttributes) { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ) +} diff --git a/apps/console/src/app/components/section-onboarding/section-onboarding.tsx b/apps/console/src/app/components/section-onboarding/section-onboarding.tsx new file mode 100644 index 00000000000..ae860f8459e --- /dev/null +++ b/apps/console/src/app/components/section-onboarding/section-onboarding.tsx @@ -0,0 +1,697 @@ +import { type IconName } from '@fortawesome/fontawesome-common-types' +import { Link as RouterLink, useNavigate, useParams } from '@tanstack/react-router' +import clsx from 'clsx' +import { ClusterStateEnum, StateEnum } from 'qovery-typescript-axios' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { ClusterInstallationGuideModal, useClusterStatuses, useClusters } from '@qovery/domains/clusters/feature' +import { CreateCloneEnvironmentModal, useDeploymentRule, useEnvironments } from '@qovery/domains/environments/feature' +import { useOrganization } from '@qovery/domains/organizations/feature' +import { useProjects } from '@qovery/domains/projects/feature' +import { useServiceStatuses, useServices } from '@qovery/domains/services/feature' +import { IconEnum } from '@qovery/shared/enums' +import { McpSuggestionCard } from '@qovery/shared/mcp-suggestion/feature' +import { AnimatedGradientText, Button, Heading, Icon, Link, LogoIcon, useModal } from '@qovery/shared/ui' +import { useLocalStorage, useSupportChat } from '@qovery/shared/util-hooks' +import { twMerge } from '@qovery/shared/util-js' +import { CompletionPartyHornIllustration } from './completion-party-horn-illustration' +import { useOrganizationOnboarding, useUpdateOrganizationOnboarding } from './use-organization-onboarding' + +const QUEUED_CLUSTER_STATUSES: ClusterStateEnum[] = [ClusterStateEnum.DEPLOYMENT_QUEUED, ClusterStateEnum.QUEUED] +const ACTIVE_DEPLOYING_CLUSTER_STATUSES: ClusterStateEnum[] = [ClusterStateEnum.DEPLOYING, ClusterStateEnum.BUILDING] +const ALL_DEPLOYING_CLUSTER_STATUSES: ClusterStateEnum[] = [ + ...QUEUED_CLUSTER_STATUSES, + ...ACTIVE_DEPLOYING_CLUSTER_STATUSES, +] + +const QUEUED_SERVICE_STATUSES: StateEnum[] = [StateEnum.DEPLOYMENT_QUEUED, StateEnum.QUEUED] +const ACTIVE_DEPLOYING_SERVICE_STATUSES: StateEnum[] = [StateEnum.DEPLOYING, StateEnum.BUILDING] +const ALL_DEPLOYING_SERVICE_STATUSES: StateEnum[] = [...QUEUED_SERVICE_STATUSES, ...ACTIVE_DEPLOYING_SERVICE_STATUSES] + +export function SectionOnboarding() { + const { organizationId = '' } = useParams({ strict: false }) + const [localDismissed, setLocalDismissed] = useLocalStorage(`onboarding_section_dismissed_${organizationId}`, false) + const { openModal, closeModal, enableAlertClickOutside } = useModal() + const navigate = useNavigate() + const { showPylonForm } = useSupportChat() + + const { data: organization } = useOrganization({ organizationId }) + const { data: onboarding, isLoading: isOnboardingLoading } = useOrganizationOnboarding({ organizationId }) + const { mutate: updateOnboarding } = useUpdateOrganizationOnboarding({ organizationId }) + + const isOnboardingActive = + Boolean(onboarding?.use_cases) && + onboarding?.status !== 'DISMISSED' && + onboarding?.status !== 'COMPLETED' && + !localDismissed + + const { data: clusters = [] } = useClusters({ organizationId, enabled: isOnboardingActive }) + const { data: clusterStatuses = [] } = useClusterStatuses({ + organizationId, + refetchInterval: isOnboardingActive ? 3000 : undefined, + enabled: isOnboardingActive, + }) + const { data: projects = [] } = useProjects({ organizationId, enabled: isOnboardingActive }) + + const firstProject = projects[0] + const { data: environments = [] } = useEnvironments({ projectId: firstProject?.id ?? '' }) + const firstEnvironment = environments[0] + const { data: services = [] } = useServices({ environmentId: firstEnvironment?.id }) + const { data: serviceStatuses } = useServiceStatuses({ environmentId: firstEnvironment?.id }) + const completionModalOpenedRef = useRef(false) + + const isClusterDeployed = useMemo( + () => clusterStatuses.some((s) => s.status === ClusterStateEnum.DEPLOYED && s.is_deployed), + [clusterStatuses] + ) + const isClusterQueued = useMemo( + () => + clusters.length > 0 && + !isClusterDeployed && + clusterStatuses.some((s) => QUEUED_CLUSTER_STATUSES.includes(s.status as ClusterStateEnum)), + [clusters.length, isClusterDeployed, clusterStatuses] + ) + const isClusterDeploying = useMemo( + () => + clusters.length > 0 && + !isClusterDeployed && + clusterStatuses.some((s) => ACTIVE_DEPLOYING_CLUSTER_STATUSES.includes(s.status as ClusterStateEnum)), + [clusters.length, isClusterDeployed, clusterStatuses] + ) + const deployingClusterStatus = useMemo( + () => clusterStatuses.find((s) => ALL_DEPLOYING_CLUSTER_STATUSES.includes(s.status as ClusterStateEnum)), + [clusterStatuses] + ) + const isClusterFailed = useMemo( + () => + !isClusterDeployed && + clusterStatuses.some( + (s) => s.status === ClusterStateEnum.DEPLOYMENT_ERROR || s.status === ClusterStateEnum.INVALID_CREDENTIALS + ), + [isClusterDeployed, clusterStatuses] + ) + const failedClusterStatus = useMemo( + () => + clusterStatuses.find( + (s) => s.status === ClusterStateEnum.DEPLOYMENT_ERROR || s.status === ClusterStateEnum.INVALID_CREDENTIALS + ), + [clusterStatuses] + ) + + const hasCluster = clusters.length > 0 + const hasEnvironment = environments.length > 0 + + const allServiceStatuses = useMemo( + () => [ + ...(serviceStatuses?.applications ?? []), + ...(serviceStatuses?.containers ?? []), + ...(serviceStatuses?.jobs ?? []), + ...(serviceStatuses?.helms ?? []), + ...(serviceStatuses?.databases ?? []), + ...(serviceStatuses?.terraforms ?? []), + ], + [serviceStatuses] + ) + const hasService = services.length > 0 + const isServiceDeployed = useMemo( + () => allServiceStatuses.some((s) => s.state === StateEnum.DEPLOYED), + [allServiceStatuses] + ) + const isServiceQueued = useMemo( + () => hasService && !isServiceDeployed && allServiceStatuses.some((s) => QUEUED_SERVICE_STATUSES.includes(s.state)), + [hasService, isServiceDeployed, allServiceStatuses] + ) + const isServiceDeploying = useMemo( + () => + hasService && + !isServiceDeployed && + allServiceStatuses.some((s) => ACTIVE_DEPLOYING_SERVICE_STATUSES.includes(s.state)), + [hasService, isServiceDeployed, allServiceStatuses] + ) + const deployingServiceStatus = useMemo( + () => allServiceStatuses.find((s) => ALL_DEPLOYING_SERVICE_STATUSES.includes(s.state)), + [allServiceStatuses] + ) + const isServiceFailed = useMemo( + () => + !isServiceDeployed && + allServiceStatuses.some((s) => s.state === StateEnum.DEPLOYMENT_ERROR || s.state === StateEnum.BUILD_ERROR), + [isServiceDeployed, allServiceStatuses] + ) + const failedServiceStatus = useMemo( + () => allServiceStatuses.find((s) => s.state === StateEnum.DEPLOYMENT_ERROR || s.state === StateEnum.BUILD_ERROR), + [allServiceStatuses] + ) + const isServiceStopped = useMemo( + () => + hasService && + !isServiceDeployed && + !isServiceQueued && + !isServiceDeploying && + !isServiceFailed && + allServiceStatuses.some((s) => s.state === StateEnum.STOPPED || s.state === StateEnum.STOP_ERROR), + [hasService, isServiceDeployed, isServiceQueued, isServiceDeploying, isServiceFailed, allServiceStatuses] + ) + const stoppedServiceStatus = useMemo( + () => allServiceStatuses.find((s) => s.state === StateEnum.STOPPED || s.state === StateEnum.STOP_ERROR), + [allServiceStatuses] + ) + + const useCases = useMemo(() => (onboarding?.use_cases ?? '').split(',').filter(Boolean), [onboarding?.use_cases]) + const hasEphemeralEnvironments = useCases.includes('ephemeral-environments') + const hasRde = useCases.includes('rde') + + const { data: deploymentRule } = useDeploymentRule({ + environmentId: firstEnvironment?.id ?? '', + enabled: !!firstEnvironment?.id, + }) + const isPreviewEnabled = hasEphemeralEnvironments && (deploymentRule?.auto_preview ?? false) + + const requiredStepsTotal = 4 + (hasEphemeralEnvironments ? 1 : 0) + const completedCount = useMemo( + () => + [ + true, + isClusterDeployed, + hasEnvironment, + isServiceDeployed, + ...(hasEphemeralEnvironments ? [isPreviewEnabled] : []), + ].filter(Boolean).length, + [isClusterDeployed, hasEnvironment, isServiceDeployed, hasEphemeralEnvironments, isPreviewEnabled] + ) + const allRequiredDone = completedCount === requiredStepsTotal + const progressPercent = Math.round((completedCount / requiredStepsTotal) * 100) + + const [clusterExpanded, setClusterExpanded] = useState(!hasCluster) + + const isDismissed = onboarding?.status === 'DISMISSED' || onboarding?.status === 'COMPLETED' || localDismissed + + const dismiss = () => { + setLocalDismissed(true) + updateOnboarding('DISMISSED') + } + + const complete = useCallback(() => { + setLocalDismissed(true) + updateOnboarding('COMPLETED') + }, [setLocalDismissed, updateOnboarding]) + + const openInstallationGuideModal = ({ isDemo = false }: { isDemo?: boolean } = {}) => + openModal({ + options: { width: 500 }, + content: ( + closeModal()} /> + ), + }) + + const openCreateEnvironmentModal = () => { + if (!firstProject) return + openModal({ + content: ( + + ), + options: { fakeModal: true }, + }) + } + + useEffect(() => { + if ( + !organization || + isOnboardingLoading || + !onboarding?.use_cases || + !allRequiredDone || + isDismissed || + completionModalOpenedRef.current + ) { + return + } + + completionModalOpenedRef.current = true + enableAlertClickOutside(false) + + openModal({ + content: ( +
+
+ +
+
+

You're all set!

+

+ Your workspace is completely ready to use. +
+ Here's what you can explore next. +

+
+
+ {hasRde && ( + + )} + +
+
+ ), + }) + }, [ + allRequiredDone, + closeModal, + complete, + enableAlertClickOutside, + hasRde, + isDismissed, + isOnboardingLoading, + navigate, + onboarding?.use_cases, + openModal, + organization, + organizationId, + showPylonForm, + ]) + + if (!organization || isOnboardingLoading) return null + if (!onboarding?.use_cases) return null + + if (isDismissed) return null + if (allRequiredDone) return null + + const stepRowClass = 'flex h-[52px] items-center gap-2 px-4 py-3' + const stepIconClass = 'w-4 text-center text-sm text-neutral-subtle' + + const CLUSTERS_OPTIONS = [ + { + highlight: true, + tag: 'Recommended', + title: 'Qovery managed', + description: + 'Qovery will install and manage the Kubernetes cluster and the underlying infrastructure on your cloud provider account.', + icon: , + compatibleWith: [IconEnum.AWS, IconEnum.GCP, IconEnum.AZURE, IconEnum.SCW_GRAY] as IconEnum[], + action: 'create-cluster' as const, + dataAction: 'onboarding__cluster-option-managed', + }, + { + highlight: false, + tag: 'Self-managed', + title: 'Bring your own cluster', + icon: 'cloud', + description: + 'You will manage the infrastructure, including any update/ upgrade. Advanced Kubernetes knowledge required.', + compatibleWith: [ + IconEnum.AWS, + IconEnum.GCP, + IconEnum.AZURE, + IconEnum.SCW_GRAY, + IconEnum.OVH_CLOUD, + IconEnum.DO, + IconEnum.ORACLE_CLOUD, + IconEnum.HETZNER, + IconEnum.IBM_CLOUD, + IconEnum.CIVO, + ] as IconEnum[], + action: 'installation-guide' as const, + isDemo: false, + dataAction: 'onboarding__cluster-option-byoc', + }, + { + highlight: false, + tag: 'Demo', + title: 'Local machine', + icon: 'laptop-code', + description: + 'Deploy a local Kubernetes cluster on your laptop using Docker Desktop. No cloud account or credit card required!', + compatibleWith: null, + action: 'installation-guide' as const, + isDemo: true, + dataAction: 'onboarding__cluster-option-demo', + }, + ] + + const getCardClass = (highlight: boolean) => + twMerge( + clsx( + 'flex flex-col gap-3 rounded-md border border-neutral bg-surface-neutral p-3 text-left transition-colors focus:outline-none', + { + 'border-brand-subtle bg-surface-brand-subtle hover:border-brand-component hover:bg-surface-brand-component': + highlight, + 'hover:bg-surface-neutral-subtle': !highlight, + } + ) + ) + + const renderClusterCardContent = (option: (typeof CLUSTERS_OPTIONS)[number]) => ( + <> + + {option.tag} + + +

+ {typeof option.icon === 'string' ? ( + + ) : ( + option.icon + )} + {option.title} +

+

{option.description}

+
+ + Compatible with + {option.compatibleWith ? ( +
+ {option.compatibleWith.map((provider) => ( + + ))} +
+ ) : ( + Every computer + )} +
+ + ) + + return ( +
+
+ Getting started + +
+ + {!allRequiredDone && ( + + )} + +
+
+ Onboarding checklist +
+
+
+ {progressPercent}% +
+ +
+
+ + + Create and setup my workspace + + +
+ +
+ + + {clusterExpanded && !hasCluster && ( +
+
+ {CLUSTERS_OPTIONS.map((option) => + option.action === 'create-cluster' ? ( + + {renderClusterCardContent(option)} + + ) : ( + + ) + )} +
+
+ )} +
+ +
+ + + Create my first environment + + {hasEnvironment ? ( + + ) : hasCluster ? ( + + ) : null} +
+ +
+ + + Create and deploy my first application + + {isServiceDeployed ? ( + + ) : isServiceQueued ? ( + Deployment queued... + ) : isServiceDeploying ? ( + + + + Deploying... + + + + ) : isServiceFailed ? ( + + Last deployment failed + + ) : isServiceStopped ? ( + + Service stopped + + ) : hasEnvironment ? ( + + + New Service + + ) : null} +
+ + {hasEphemeralEnvironments && ( +
+ + + Turn on preview environment + + {isPreviewEnabled ? ( + + ) : isServiceDeployed ? ( + + + Configure + + ) : null} +
+ )} +
+
+
+ ) +} + +export default SectionOnboarding diff --git a/apps/console/src/app/components/section-onboarding/use-organization-onboarding.ts b/apps/console/src/app/components/section-onboarding/use-organization-onboarding.ts new file mode 100644 index 00000000000..4cc4512b716 --- /dev/null +++ b/apps/console/src/app/components/section-onboarding/use-organization-onboarding.ts @@ -0,0 +1,24 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { type OrganizationOnboardingStatusEnum } from 'qovery-typescript-axios' +import { mutations } from '@qovery/domains/organizations/data-access' +import { queries } from '@qovery/state/util-queries' + +export type { OrganizationOnboardingStatusEnum } + +export function useOrganizationOnboarding({ organizationId }: { organizationId: string }) { + return useQuery({ + ...queries.organizations.onboarding({ organizationId }), + staleTime: 5 * 60 * 1000, + }) +} + +export function useUpdateOrganizationOnboarding({ organizationId }: { organizationId: string }) { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: (status: OrganizationOnboardingStatusEnum) => + mutations.updateOrganizationOnboarding({ organizationId, onboardingPatchRequest: { status } }), + onSuccess(data) { + queryClient.setQueryData(queries.organizations.onboarding({ organizationId }).queryKey, data) + }, + }) +} diff --git a/apps/console/src/routes/_authenticated/organization/$organizationId/overview.tsx b/apps/console/src/routes/_authenticated/organization/$organizationId/overview.tsx index ccb3dce9117..7f2ed324533 100644 --- a/apps/console/src/routes/_authenticated/organization/$organizationId/overview.tsx +++ b/apps/console/src/routes/_authenticated/organization/$organizationId/overview.tsx @@ -3,6 +3,7 @@ import { SectionProductionHealth, useClusters } from '@qovery/domains/clusters/f import { OrganizationOverview } from '@qovery/domains/organizations/feature' import { ProjectList } from '@qovery/domains/projects/feature' import { useDocumentTitle } from '@qovery/shared/util-hooks' +import { SectionOnboarding } from '../../../../app/components/section-onboarding/section-onboarding' export const Route = createFileRoute('/_authenticated/organization/$organizationId/overview')({ component: RouteComponent, @@ -17,6 +18,7 @@ function RouteComponent() { return (
+ {hasClusters ? : null} diff --git a/libs/domains/clusters/feature/src/lib/section-production-health/section-production-health.spec.tsx b/libs/domains/clusters/feature/src/lib/section-production-health/section-production-health.spec.tsx index 49b79962701..9f5979b687b 100644 --- a/libs/domains/clusters/feature/src/lib/section-production-health/section-production-health.spec.tsx +++ b/libs/domains/clusters/feature/src/lib/section-production-health/section-production-health.spec.tsx @@ -1,35 +1,17 @@ import { renderWithProviders, screen } from '@qovery/shared/util-tests' import { SectionProductionHealth } from './section-production-health' -const mockUseClusterCreationRestriction = jest.fn(() => ({ - isNoCreditCardRestriction: false, -})) -const mockUseClusters = jest.fn(() => ({ data: [] })) -const mockUseClusterStatuses = jest.fn(() => ({ data: [] })) - -jest.mock('../hooks/use-cluster-creation-restriction/use-cluster-creation-restriction', () => ({ - useClusterCreationRestriction: (params: { organizationId: string }) => mockUseClusterCreationRestriction(params), -})) +const mockUseClusters = jest.fn() +const mockUseClusterStatuses = jest.fn() jest.mock('../hooks/use-clusters/use-clusters', () => ({ __esModule: true, - default: (params: { organizationId: string }) => mockUseClusters(params), + default: () => mockUseClusters(), })) jest.mock('../hooks/use-cluster-statuses/use-cluster-statuses', () => ({ __esModule: true, - default: (params: { organizationId: string }) => mockUseClusterStatuses(params), -})) - -const mockOpenModal = jest.fn() -const mockCloseModal = jest.fn() - -jest.mock('@qovery/shared/ui', () => ({ - ...jest.requireActual('@qovery/shared/ui'), - useModal: () => ({ - openModal: mockOpenModal, - closeModal: mockCloseModal, - }), + default: () => mockUseClusterStatuses(), })) const mockUseParams = jest.fn((_options?: { strict?: boolean }) => ({ organizationId: 'test-org-id' })) @@ -67,30 +49,25 @@ describe('SectionProductionHealth', () => { beforeEach(() => { jest.clearAllMocks() mockUseParams.mockReturnValue({ organizationId: 'test-org-id' }) - mockUseClusterCreationRestriction.mockReturnValue({ isNoCreditCardRestriction: false }) - mockUseClusters.mockReturnValue({ data: [] }) - mockUseClusterStatuses.mockReturnValue({ data: [] }) + mockUseClusters.mockReturnValue({ data: [], isLoading: false }) + mockUseClusterStatuses.mockReturnValue({ data: [], isLoading: false }) }) - it('should render the cluster option cards when no cluster exists', () => { + it('should render the empty state when no production cluster exists', () => { renderWithProviders() expect(screen.getByRole('heading', { name: 'Production health' })).toBeInTheDocument() - expect(screen.getByText('Let your agent do the configuration with')).toBeInTheDocument() - expect(screen.getByText('Just install our AI skills and ask your agent to get you started!')).toBeInTheDocument() - expect(screen.getByText('curl -fsSL https://skill.qovery.com/install.sh | bash')).toBeInTheDocument() - expect(screen.getByText('Qovery managed')).toBeInTheDocument() - expect(screen.getByText('Bring your own cluster')).toBeInTheDocument() - expect(screen.getByText('Local machine (demo)')).toBeInTheDocument() + expect(screen.getByText('No production cluster created yet')).toBeInTheDocument() + expect(screen.getByText('Create cluster')).toBeInTheDocument() }) - it('should keep the Qovery managed card linked to cluster creation', () => { + it('should keep the create cluster button linked to cluster creation', () => { renderWithProviders() - const managedLink = screen.getByText('Qovery managed').closest('a') - expect(managedLink).toBeInTheDocument() - expect(managedLink).toHaveAttribute('data-to', '/organization/$organizationId/cluster/new') - expect(managedLink).toHaveAttribute('data-params', JSON.stringify({ organizationId: 'test-org-id' })) + const createLink = screen.getByText('Create cluster').closest('a') + expect(createLink).toBeInTheDocument() + expect(createLink).toHaveAttribute('data-to', '/organization/$organizationId/cluster/new') + expect(createLink).toHaveAttribute('data-params', JSON.stringify({ organizationId: 'test-org-id' })) }) it('should keep the all clusters link pointing to the clusters list', () => { @@ -101,40 +78,4 @@ describe('SectionProductionHealth', () => { expect(allClustersLink).toHaveAttribute('data-to', '/organization/$organizationId/clusters') expect(allClustersLink).toHaveAttribute('data-params', JSON.stringify({ organizationId: 'test-org-id' })) }) - - it('should open the add credit card modal from self-managed during free trial restriction', async () => { - mockUseClusterCreationRestriction.mockReturnValue({ isNoCreditCardRestriction: true }) - const { userEvent } = renderWithProviders() - - const selfManagedButton = screen.getByText('Bring your own cluster').closest('button') - expect(selfManagedButton).toBeInTheDocument() - - await userEvent.click(selfManagedButton as Element) - - expect(mockOpenModal).toHaveBeenCalledWith( - expect.objectContaining({ - content: expect.objectContaining({ - props: expect.objectContaining({ organizationId: 'test-org-id' }), - }), - }) - ) - }) - - it('should keep the local machine card opening the installation guide modal', async () => { - mockUseClusterCreationRestriction.mockReturnValue({ isNoCreditCardRestriction: true }) - const { userEvent } = renderWithProviders() - - const demoButton = screen.getByText('Local machine (demo)').closest('button') - expect(demoButton).toBeInTheDocument() - - await userEvent.click(demoButton as Element) - - expect(mockOpenModal).toHaveBeenCalledWith( - expect.objectContaining({ - content: expect.objectContaining({ - props: expect.objectContaining({ isDemo: true, type: 'ON_PREMISE' }), - }), - }) - ) - }) }) diff --git a/libs/domains/clusters/feature/src/lib/section-production-health/section-production-health.tsx b/libs/domains/clusters/feature/src/lib/section-production-health/section-production-health.tsx index a51a71b8aae..b6aab9b90eb 100644 --- a/libs/domains/clusters/feature/src/lib/section-production-health/section-production-health.tsx +++ b/libs/domains/clusters/feature/src/lib/section-production-health/section-production-health.tsx @@ -1,104 +1,12 @@ -import { type IconName } from '@fortawesome/fontawesome-common-types' -import { Link as RouterLink, useParams } from '@tanstack/react-router' -import clsx from 'clsx' -import { type ReactNode, useMemo } from 'react' -import { IconEnum } from '@qovery/shared/enums' -import { McpSuggestionCard } from '@qovery/shared/mcp-suggestion/feature' -import { EmptyState, Heading, Icon, Link, LogoIcon, Section, useModal } from '@qovery/shared/ui' -import { twMerge } from '@qovery/shared/util-js' -import { AddCreditCardModalFeature } from '../add-credit-card-modal-feature/add-credit-card-modal-feature' -import { ClusterInstallationGuideModal } from '../cluster-installation-guide-modal/cluster-installation-guide-modal' +import { useParams } from '@tanstack/react-router' +import { useMemo } from 'react' +import { EmptyState, Heading, Icon, Link, Section } from '@qovery/shared/ui' import { ClusterProductionHealthSummaryCard } from '../cluster-production-health-summary-card/cluster-production-health-summary-card' -import { useClusterCreationRestriction } from '../hooks/use-cluster-creation-restriction/use-cluster-creation-restriction' import useClusterStatuses from '../hooks/use-cluster-statuses/use-cluster-statuses' import useClusters from '../hooks/use-clusters/use-clusters' -type ClusterOption = { - highlight: boolean - tag: string - title: string - description: string - icon: IconName | ReactNode - compatibleWith?: IconEnum[] - dataAction: string -} & ( - | { - action: 'create-cluster' - } - | { - action: 'installation-guide' - isDemo?: boolean - requiresCreditCardOnFreeTrial?: boolean - } -) - -const CLUSTERS_OPTIONS: ClusterOption[] = [ - { - highlight: true, - tag: 'Recommended', - title: 'Qovery managed', - description: - 'Qovery will install and manage the Kubernetes cluster and the underlying infrastructure on your cloud provider account.', - icon: , - compatibleWith: [IconEnum.AWS, IconEnum.GCP, IconEnum.AZURE, IconEnum.SCW_GRAY], - action: 'create-cluster', - dataAction: 'org-overview__cluster-option-managed', - }, - { - highlight: false, - tag: 'Self-managed', - title: 'Bring your own cluster', - icon: 'cloud-fog', - description: - 'You will manage the infrastructure, including any update/ upgrade. Advanced Kubernetes knowledge required.', - compatibleWith: [ - IconEnum.AWS, - IconEnum.GCP, - IconEnum.AZURE, - IconEnum.SCW_GRAY, - IconEnum.OVH_CLOUD, - IconEnum.DO, - IconEnum.ORACLE_CLOUD, - IconEnum.HETZNER, - IconEnum.IBM_CLOUD, - IconEnum.CIVO, - ], - action: 'installation-guide', - requiresCreditCardOnFreeTrial: true, - dataAction: 'org-overview__cluster-option-byoc', - }, - { - highlight: false, - tag: 'Demo', - title: 'Local machine (demo)', - icon: 'laptop-code', - description: - 'Deploy a local Kubernetes cluster on your laptop using Docker. No cloud account or credit card required!', - action: 'installation-guide', - isDemo: true, - dataAction: 'org-overview__cluster-option-demo', - }, -] - -const RELATED_DOCUMENTATION: { title: string; url: string }[] = [ - { - title: 'What is a cluster?', - url: 'https://www.qovery.com/docs/configuration/clusters/#what-is-a-cluster', - }, - { - title: 'What does the qovery managed cluster offer me?', - url: 'https://www.qovery.com/docs/configuration/clusters/#creating-a-cluster', - }, - { - title: 'What is a self-managed cluster?', - url: 'https://www.qovery.com/docs/configuration/clusters/#what-is-a-self-managed-cluster', - }, -] - export function SectionProductionHealth() { const { organizationId = '' }: { organizationId: string } = useParams({ strict: false }) - const { openModal, closeModal } = useModal() - const { isNoCreditCardRestriction } = useClusterCreationRestriction({ organizationId }) const { data: clusters = [] } = useClusters({ organizationId, suspense: true }) const { data: clusterStatuses = [] } = useClusterStatuses({ organizationId, @@ -108,74 +16,6 @@ export function SectionProductionHealth() { const clusterProduction = useMemo(() => clusters?.filter((cluster) => cluster.production), [clusters]) ?? [] - const openInstallationGuideModal = ({ isDemo = false }: { isDemo?: boolean } = {}) => - openModal({ - options: { - width: 500, - }, - content: ( - closeModal()} /> - ), - }) - - const openCreditCardModal = () => - openModal({ - content: , - }) - - const getOptionCardClassName = (highlight: boolean) => - twMerge( - clsx( - 'flex flex-col gap-4 rounded-md border border-neutral bg-surface-neutral p-4 text-left transition-colors focus:outline-none', - { - 'border-brand-subtle bg-surface-brand-subtle hover:border-brand-component hover:bg-surface-brand-component focus-visible:border-brand-component focus-visible:bg-surface-brand-component': - highlight, - 'hover:bg-surface-neutral-subtle focus-visible:border-brand-component focus-visible:bg-surface-neutral-subtle': - !highlight, - } - ) - ) - - const renderOptionCardContent = (option: ClusterOption) => ( - <> - - {option.tag} - - -

- {typeof option.icon === 'string' ? ( - - ) : ( - option.icon - )} - {option.title} -

-

{option.description}

-
- - Compatible with - {option.compatibleWith ? ( -
- {option.compatibleWith.map((provider) => ( - - ))} -
- ) : ( - Every computer - )} -
- - ) - return (
@@ -193,94 +33,23 @@ export function SectionProductionHealth() {
{clusterProduction.length === 0 ? ( - (clusters?.length ?? 0) > 0 ? ( - + - - - Create cluster - - - ) : ( -
- ) + + Create cluster + + ) : ( )} diff --git a/libs/domains/environments/feature/src/lib/create-clone-environment-modal/create-clone-environment-modal.tsx b/libs/domains/environments/feature/src/lib/create-clone-environment-modal/create-clone-environment-modal.tsx index 8517deca4da..1a8af88bfdd 100644 --- a/libs/domains/environments/feature/src/lib/create-clone-environment-modal/create-clone-environment-modal.tsx +++ b/libs/domains/environments/feature/src/lib/create-clone-environment-modal/create-clone-environment-modal.tsx @@ -22,6 +22,7 @@ export interface CreateCloneEnvironmentModalProps { organizationId: string environmentToClone?: Environment onClose: () => void + onSuccess?: (environmentId: string) => void type?: EnvironmentModeEnum } @@ -30,6 +31,7 @@ export function CreateCloneEnvironmentModal({ organizationId, environmentToClone, onClose, + onSuccess, type, }: CreateCloneEnvironmentModalProps) { const navigate = useNavigate() @@ -55,6 +57,16 @@ export function CreateCloneEnvironmentModal({ }) methods.watch(() => enableAlertClickOutside(methods.formState.isDirty)) + const handleResult = (environmentId: string, project_id: string) => { + if (onSuccess) { + onSuccess(environmentId) + } else { + navigate({ + to: `/organization/${organizationId}/project/${project_id}/environment/${environmentId}/overview`, + }) + } + } + const onSubmit = methods.handleSubmit(async ({ name, cluster, mode, project_id }) => { if (environmentToClone) { const result = await cloneEnvironment({ @@ -66,10 +78,7 @@ export function CreateCloneEnvironmentModal({ project_id, }, }) - - navigate({ - to: `/organization/${organizationId}/project/${project_id}/environment/${result.id}/overview`, - }) + handleResult(result.id, project_id) } else { const result = await createEnvironment({ projectId: project_id, @@ -79,9 +88,7 @@ export function CreateCloneEnvironmentModal({ cluster: cluster, }, }) - navigate({ - to: `/organization/${organizationId}/project/${project_id}/environment/${result.id}/overview`, - }) + handleResult(result.id, project_id) } onClose() }) diff --git a/libs/domains/environments/feature/src/lib/hooks/use-deployment-rule/use-deployment-rule.ts b/libs/domains/environments/feature/src/lib/hooks/use-deployment-rule/use-deployment-rule.ts index 54bfe459898..35ce2c6aa52 100644 --- a/libs/domains/environments/feature/src/lib/hooks/use-deployment-rule/use-deployment-rule.ts +++ b/libs/domains/environments/feature/src/lib/hooks/use-deployment-rule/use-deployment-rule.ts @@ -3,11 +3,13 @@ import { queries } from '@qovery/state/util-queries' export interface UseDeploymentRuleProps { environmentId: string + enabled?: boolean } -export function useDeploymentRule({ environmentId }: UseDeploymentRuleProps) { +export function useDeploymentRule({ environmentId, enabled }: UseDeploymentRuleProps) { return useQuery({ ...queries.environments.deploymentRule({ environmentId }), + enabled, }) } diff --git a/libs/domains/organizations/data-access/src/lib/domains-organizations-data-access.spec.ts b/libs/domains/organizations/data-access/src/lib/domains-organizations-data-access.spec.ts new file mode 100644 index 00000000000..fc6ccd877b1 --- /dev/null +++ b/libs/domains/organizations/data-access/src/lib/domains-organizations-data-access.spec.ts @@ -0,0 +1,41 @@ +import { OrganizationMainCallsApi, OrganizationOnboardingStatusEnum } from 'qovery-typescript-axios' +import { mutations, organizations } from './domains-organizations-data-access' + +describe('organizations.onboarding', () => { + it('should return onboarding data', async () => { + const mockData = { use_cases: 'ephemeral-environments,rde', status: OrganizationOnboardingStatusEnum.IN_PROGRESS } + jest + .spyOn(OrganizationMainCallsApi.prototype, 'getOrganizationOnboarding') + .mockResolvedValue({ data: mockData } as any) + + const query = organizations.onboarding({ organizationId: 'org-1' }) + const result = await query.queryFn({} as any) + + expect(OrganizationMainCallsApi.prototype.getOrganizationOnboarding).toHaveBeenCalledWith('org-1') + expect(result).toEqual(mockData) + }) + + it('should have the organizationId in the queryKey', () => { + const query = organizations.onboarding({ organizationId: 'org-1' }) + expect(query.queryKey).toContain('org-1') + }) +}) + +describe('mutations.updateOrganizationOnboarding', () => { + it('should call updateOrganizationOnboarding with correct args', async () => { + const mockData = { use_cases: 'rde', status: OrganizationOnboardingStatusEnum.DISMISSED } + jest + .spyOn(OrganizationMainCallsApi.prototype, 'updateOrganizationOnboarding') + .mockResolvedValue({ data: mockData } as any) + + const result = await mutations.updateOrganizationOnboarding({ + organizationId: 'org-1', + onboardingPatchRequest: { status: OrganizationOnboardingStatusEnum.DISMISSED }, + }) + + expect(OrganizationMainCallsApi.prototype.updateOrganizationOnboarding).toHaveBeenCalledWith('org-1', { + status: OrganizationOnboardingStatusEnum.DISMISSED, + }) + expect(result).toEqual(mockData) + }) +}) diff --git a/libs/domains/organizations/data-access/src/lib/domains-organizations-data-access.ts b/libs/domains/organizations/data-access/src/lib/domains-organizations-data-access.ts index 77853369ec6..777c25b9c06 100644 --- a/libs/domains/organizations/data-access/src/lib/domains-organizations-data-access.ts +++ b/libs/domains/organizations/data-access/src/lib/domains-organizations-data-access.ts @@ -31,6 +31,7 @@ import { OrganizationLabelsGroupApi, type OrganizationLabelsGroupCreateRequest, OrganizationMainCallsApi, + type OrganizationOnboardingPatchRequest, type OrganizationRequest, OrganizationWebhookApi, type OrganizationWebhookCreateRequest, @@ -396,6 +397,13 @@ export const organizations = createQueryKeys('organizations', { return response.data }, }), + onboarding: ({ organizationId }: { organizationId: string }) => ({ + queryKey: [organizationId], + async queryFn() { + const response = await organizationApi.getOrganizationOnboarding(organizationId) + return response.data + }, + }), servicesSearch: ({ organizationId }: { organizationId: string }) => ({ queryKey: [organizationId], async queryFn() { @@ -831,4 +839,14 @@ export const mutations = { const response = await billingApi.changePlan(organizationId, { plan }) return response.data }, + async updateOrganizationOnboarding({ + organizationId, + onboardingPatchRequest, + }: { + organizationId: string + onboardingPatchRequest: OrganizationOnboardingPatchRequest + }) { + const response = await organizationApi.updateOrganizationOnboarding(organizationId, onboardingPatchRequest) + return response.data + }, } diff --git a/package.json b/package.json index b22c5701ba2..c7c4212cafb 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.918", + "qovery-typescript-axios": "1.1.919", "react": "18.3.1", "react-country-flag": "3.0.2", "react-datepicker": "4.12.0", diff --git a/yarn.lock b/yarn.lock index 99bb701ae2e..bad0e7431eb 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.918 + qovery-typescript-axios: 1.1.919 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.918": - version: 1.1.918 - resolution: "qovery-typescript-axios@npm:1.1.918" +"qovery-typescript-axios@npm:1.1.919": + version: 1.1.919 + resolution: "qovery-typescript-axios@npm:1.1.919" dependencies: axios: 1.15.2 - checksum: a9146e698954fa185393ef662d922d0e9d9b4e16ff2566fe32993d6530dba1b2657d88fc48a37ebadc441667354104326c96791ca7244c7ef3d61edaa35efcab + checksum: dea1b215b607bc3e9c94ab1faa5389f5d1319981f0a775c7bfab2a1e1c42ba42c1e6893b7d6763b8a19e0856156dd116adf4e8aa07503d43237e29ac0dd4e553 languageName: node linkType: hard From 4716826466dae587142c5f185b645ce0a939e3a8 Mon Sep 17 00:00:00 2001 From: Romain Billard Date: Fri, 3 Jul 2026 17:11:31 +0200 Subject: [PATCH 10/15] feat(blueprints): support for multiple versions and creation flow improvements (#2791) * Add support for multiple versions * Add "Update available" and "Up-to-date" badges for blueprint-based services * Made useBlueprintUpdate's blueprintId param mandatory * Update .gitignore * Refactor blueprint-based service creation flow to better manage data fetching and redirections * Skeleton is not longer displayed when clicking on "Continue" * Improve the creation flow by making the "overrides" section optional * Fix lint issue * Update failing and outdated unit tests --- .gitignore | 1 + apps/console/src/routeTree.gen.ts | 78 ++++ .../$serviceFamily/blueprint-setup.tsx | 21 + .../$provider/$serviceFamily/index.tsx | 16 +- .../$provider/$serviceFamily/overrides.tsx | 24 ++ .../$serviceFamily/service-information.tsx | 17 + .../$provider/$serviceFamily/summary.tsx | 8 +- .../src/lib/domains-services-data-access.ts | 7 + libs/domains/services/feature/src/index.ts | 1 + .../use-blueprint-catalog-service-manifest.ts | 29 +- .../use-blueprint-update.ts | 18 + .../blueprint-configuration-view.tsx | 366 ++++++++++++------ .../overrides-section-card.tsx} | 4 +- .../blueprint-create-context.ts | 12 +- .../blueprint-creation-flow.spec.tsx | 308 ++++++++++----- .../blueprint/blueprint-creation-flow.tsx | 82 ++-- .../blueprint-creation-utils.spec.ts | 18 + .../blueprint-creation-utils.ts | 24 ++ .../blueprint-manifest-context.tsx | 88 +++++ .../blueprint-step-summary.tsx | 32 +- .../service-header/service-header.spec.tsx | 80 +++- .../service-header/service-header.tsx | 35 ++ .../service-container-create-params.ts | 5 - 23 files changed, 944 insertions(+), 330 deletions(-) create mode 100644 apps/console/src/routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/create/blueprint/$provider/$serviceFamily/blueprint-setup.tsx create mode 100644 apps/console/src/routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/create/blueprint/$provider/$serviceFamily/overrides.tsx create mode 100644 apps/console/src/routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/create/blueprint/$provider/$serviceFamily/service-information.tsx create mode 100644 libs/domains/services/feature/src/lib/hooks/use-blueprint-update/use-blueprint-update.ts rename libs/domains/services/feature/src/lib/service-creation-flow/blueprint/blueprint-configuration-view/blueprint-creation-components/{overrides-section/overrides-section.tsx => overrides-section-card/overrides-section-card.tsx} (89%) create mode 100644 libs/domains/services/feature/src/lib/service-creation-flow/blueprint/blueprint-manifest-context/blueprint-manifest-context.tsx diff --git a/.gitignore b/.gitignore index 2898e7c588b..9422ebbd6af 100644 --- a/.gitignore +++ b/.gitignore @@ -43,6 +43,7 @@ node_modules .settings/ *.sublime-workspace .zed* +.codex/environments # IDE - VSCode .vscode/* diff --git a/apps/console/src/routeTree.gen.ts b/apps/console/src/routeTree.gen.ts index 9bf6c7e1077..866abbfcc35 100644 --- a/apps/console/src/routeTree.gen.ts +++ b/apps/console/src/routeTree.gen.ts @@ -186,6 +186,9 @@ import { Route as AuthenticatedOrganizationOrganizationIdProjectProjectIdEnviron import { Route as AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceCreateBlueprintProviderServiceFamilyRouteRouteImport } from './routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/create/blueprint/$provider/$serviceFamily/route' import { Route as AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceCreateBlueprintProviderServiceFamilyIndexRouteImport } from './routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/create/blueprint/$provider/$serviceFamily/index' import { Route as AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceCreateBlueprintProviderServiceFamilySummaryRouteImport } from './routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/create/blueprint/$provider/$serviceFamily/summary' +import { Route as AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceCreateBlueprintProviderServiceFamilyServiceInformationRouteImport } from './routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/create/blueprint/$provider/$serviceFamily/service-information' +import { Route as AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceCreateBlueprintProviderServiceFamilyOverridesRouteImport } from './routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/create/blueprint/$provider/$serviceFamily/overrides' +import { Route as AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceCreateBlueprintProviderServiceFamilyBlueprintSetupRouteImport } from './routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/create/blueprint/$provider/$serviceFamily/blueprint-setup' import { Route as AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdMonitoringAlertsAlertIdEditRouteImport } from './routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/monitoring/alerts.$alertId.edit' import { Route as AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdMonitoringAlertsCreateMetricMetricRouteImport } from './routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/monitoring/alerts.create.metric.$metric' @@ -1646,6 +1649,33 @@ const AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironm AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceCreateBlueprintProviderServiceFamilyRouteRoute, } as any, ) +const AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceCreateBlueprintProviderServiceFamilyServiceInformationRoute = + AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceCreateBlueprintProviderServiceFamilyServiceInformationRouteImport.update( + { + id: '/service-information', + path: '/service-information', + getParentRoute: () => + AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceCreateBlueprintProviderServiceFamilyRouteRoute, + } as any, + ) +const AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceCreateBlueprintProviderServiceFamilyOverridesRoute = + AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceCreateBlueprintProviderServiceFamilyOverridesRouteImport.update( + { + id: '/overrides', + path: '/overrides', + getParentRoute: () => + AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceCreateBlueprintProviderServiceFamilyRouteRoute, + } as any, + ) +const AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceCreateBlueprintProviderServiceFamilyBlueprintSetupRoute = + AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceCreateBlueprintProviderServiceFamilyBlueprintSetupRouteImport.update( + { + id: '/blueprint-setup', + path: '/blueprint-setup', + getParentRoute: () => + AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceCreateBlueprintProviderServiceFamilyRouteRoute, + } as any, + ) const AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdMonitoringAlertsAlertIdEditRoute = AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdMonitoringAlertsAlertIdEditRouteImport.update( { @@ -1841,6 +1871,9 @@ export interface FileRoutesByFullPath { '/organization/$organizationId/project/$projectId/environment/$environmentId/service/create/blueprint/$provider/$serviceFamily': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceCreateBlueprintProviderServiceFamilyRouteRouteWithChildren '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/deployments/logs/$executionId': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdDeploymentsLogsExecutionIdRoute '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/monitoring/alerts/$alertId/edit': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdMonitoringAlertsAlertIdEditRoute + '/organization/$organizationId/project/$projectId/environment/$environmentId/service/create/blueprint/$provider/$serviceFamily/blueprint-setup': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceCreateBlueprintProviderServiceFamilyBlueprintSetupRoute + '/organization/$organizationId/project/$projectId/environment/$environmentId/service/create/blueprint/$provider/$serviceFamily/overrides': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceCreateBlueprintProviderServiceFamilyOverridesRoute + '/organization/$organizationId/project/$projectId/environment/$environmentId/service/create/blueprint/$provider/$serviceFamily/service-information': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceCreateBlueprintProviderServiceFamilyServiceInformationRoute '/organization/$organizationId/project/$projectId/environment/$environmentId/service/create/blueprint/$provider/$serviceFamily/summary': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceCreateBlueprintProviderServiceFamilySummaryRoute '/organization/$organizationId/project/$projectId/environment/$environmentId/service/create/blueprint/$provider/$serviceFamily/': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceCreateBlueprintProviderServiceFamilyIndexRoute '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/monitoring/alerts/create/metric/$metric': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdMonitoringAlertsCreateMetricMetricRoute @@ -2001,6 +2034,9 @@ export interface FileRoutesByTo { '/organization/$organizationId/project/$projectId/environment/$environmentId/service/create/terraform': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceCreateTerraformIndexRoute '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/deployments/logs/$executionId': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdDeploymentsLogsExecutionIdRoute '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/monitoring/alerts/$alertId/edit': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdMonitoringAlertsAlertIdEditRoute + '/organization/$organizationId/project/$projectId/environment/$environmentId/service/create/blueprint/$provider/$serviceFamily/blueprint-setup': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceCreateBlueprintProviderServiceFamilyBlueprintSetupRoute + '/organization/$organizationId/project/$projectId/environment/$environmentId/service/create/blueprint/$provider/$serviceFamily/overrides': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceCreateBlueprintProviderServiceFamilyOverridesRoute + '/organization/$organizationId/project/$projectId/environment/$environmentId/service/create/blueprint/$provider/$serviceFamily/service-information': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceCreateBlueprintProviderServiceFamilyServiceInformationRoute '/organization/$organizationId/project/$projectId/environment/$environmentId/service/create/blueprint/$provider/$serviceFamily/summary': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceCreateBlueprintProviderServiceFamilySummaryRoute '/organization/$organizationId/project/$projectId/environment/$environmentId/service/create/blueprint/$provider/$serviceFamily': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceCreateBlueprintProviderServiceFamilyIndexRoute '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/monitoring/alerts/create/metric/$metric': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdMonitoringAlertsCreateMetricMetricRoute @@ -2183,6 +2219,9 @@ export interface FileRoutesById { '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/create/blueprint/$provider/$serviceFamily': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceCreateBlueprintProviderServiceFamilyRouteRouteWithChildren '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/deployments/logs/$executionId': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdDeploymentsLogsExecutionIdRoute '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/monitoring/alerts/$alertId/edit': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdMonitoringAlertsAlertIdEditRoute + '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/create/blueprint/$provider/$serviceFamily/blueprint-setup': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceCreateBlueprintProviderServiceFamilyBlueprintSetupRoute + '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/create/blueprint/$provider/$serviceFamily/overrides': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceCreateBlueprintProviderServiceFamilyOverridesRoute + '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/create/blueprint/$provider/$serviceFamily/service-information': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceCreateBlueprintProviderServiceFamilyServiceInformationRoute '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/create/blueprint/$provider/$serviceFamily/summary': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceCreateBlueprintProviderServiceFamilySummaryRoute '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/create/blueprint/$provider/$serviceFamily/': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceCreateBlueprintProviderServiceFamilyIndexRoute '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/monitoring/alerts/create/metric/$metric': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdMonitoringAlertsCreateMetricMetricRoute @@ -2365,6 +2404,9 @@ export interface FileRouteTypes { | '/organization/$organizationId/project/$projectId/environment/$environmentId/service/create/blueprint/$provider/$serviceFamily' | '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/deployments/logs/$executionId' | '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/monitoring/alerts/$alertId/edit' + | '/organization/$organizationId/project/$projectId/environment/$environmentId/service/create/blueprint/$provider/$serviceFamily/blueprint-setup' + | '/organization/$organizationId/project/$projectId/environment/$environmentId/service/create/blueprint/$provider/$serviceFamily/overrides' + | '/organization/$organizationId/project/$projectId/environment/$environmentId/service/create/blueprint/$provider/$serviceFamily/service-information' | '/organization/$organizationId/project/$projectId/environment/$environmentId/service/create/blueprint/$provider/$serviceFamily/summary' | '/organization/$organizationId/project/$projectId/environment/$environmentId/service/create/blueprint/$provider/$serviceFamily/' | '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/monitoring/alerts/create/metric/$metric' @@ -2525,6 +2567,9 @@ export interface FileRouteTypes { | '/organization/$organizationId/project/$projectId/environment/$environmentId/service/create/terraform' | '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/deployments/logs/$executionId' | '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/monitoring/alerts/$alertId/edit' + | '/organization/$organizationId/project/$projectId/environment/$environmentId/service/create/blueprint/$provider/$serviceFamily/blueprint-setup' + | '/organization/$organizationId/project/$projectId/environment/$environmentId/service/create/blueprint/$provider/$serviceFamily/overrides' + | '/organization/$organizationId/project/$projectId/environment/$environmentId/service/create/blueprint/$provider/$serviceFamily/service-information' | '/organization/$organizationId/project/$projectId/environment/$environmentId/service/create/blueprint/$provider/$serviceFamily/summary' | '/organization/$organizationId/project/$projectId/environment/$environmentId/service/create/blueprint/$provider/$serviceFamily' | '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/monitoring/alerts/create/metric/$metric' @@ -2706,6 +2751,9 @@ export interface FileRouteTypes { | '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/create/blueprint/$provider/$serviceFamily' | '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/deployments/logs/$executionId' | '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/monitoring/alerts/$alertId/edit' + | '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/create/blueprint/$provider/$serviceFamily/blueprint-setup' + | '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/create/blueprint/$provider/$serviceFamily/overrides' + | '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/create/blueprint/$provider/$serviceFamily/service-information' | '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/create/blueprint/$provider/$serviceFamily/summary' | '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/create/blueprint/$provider/$serviceFamily/' | '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/monitoring/alerts/create/metric/$metric' @@ -3959,6 +4007,27 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceCreateBlueprintProviderServiceFamilySummaryRouteImport parentRoute: typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceCreateBlueprintProviderServiceFamilyRouteRoute } + '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/create/blueprint/$provider/$serviceFamily/service-information': { + id: '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/create/blueprint/$provider/$serviceFamily/service-information' + path: '/service-information' + fullPath: '/organization/$organizationId/project/$projectId/environment/$environmentId/service/create/blueprint/$provider/$serviceFamily/service-information' + preLoaderRoute: typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceCreateBlueprintProviderServiceFamilyServiceInformationRouteImport + parentRoute: typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceCreateBlueprintProviderServiceFamilyRouteRoute + } + '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/create/blueprint/$provider/$serviceFamily/overrides': { + id: '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/create/blueprint/$provider/$serviceFamily/overrides' + path: '/overrides' + fullPath: '/organization/$organizationId/project/$projectId/environment/$environmentId/service/create/blueprint/$provider/$serviceFamily/overrides' + preLoaderRoute: typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceCreateBlueprintProviderServiceFamilyOverridesRouteImport + parentRoute: typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceCreateBlueprintProviderServiceFamilyRouteRoute + } + '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/create/blueprint/$provider/$serviceFamily/blueprint-setup': { + id: '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/create/blueprint/$provider/$serviceFamily/blueprint-setup' + path: '/blueprint-setup' + fullPath: '/organization/$organizationId/project/$projectId/environment/$environmentId/service/create/blueprint/$provider/$serviceFamily/blueprint-setup' + preLoaderRoute: typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceCreateBlueprintProviderServiceFamilyBlueprintSetupRouteImport + parentRoute: typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceCreateBlueprintProviderServiceFamilyRouteRoute + } '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/monitoring/alerts/$alertId/edit': { id: '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/monitoring/alerts/$alertId/edit' path: '/$alertId/edit' @@ -4541,12 +4610,21 @@ const AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironm ) interface AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceCreateBlueprintProviderServiceFamilyRouteRouteChildren { + AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceCreateBlueprintProviderServiceFamilyBlueprintSetupRoute: typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceCreateBlueprintProviderServiceFamilyBlueprintSetupRoute + AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceCreateBlueprintProviderServiceFamilyOverridesRoute: typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceCreateBlueprintProviderServiceFamilyOverridesRoute + AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceCreateBlueprintProviderServiceFamilyServiceInformationRoute: typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceCreateBlueprintProviderServiceFamilyServiceInformationRoute AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceCreateBlueprintProviderServiceFamilySummaryRoute: typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceCreateBlueprintProviderServiceFamilySummaryRoute AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceCreateBlueprintProviderServiceFamilyIndexRoute: typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceCreateBlueprintProviderServiceFamilyIndexRoute } const AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceCreateBlueprintProviderServiceFamilyRouteRouteChildren: AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceCreateBlueprintProviderServiceFamilyRouteRouteChildren = { + AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceCreateBlueprintProviderServiceFamilyBlueprintSetupRoute: + AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceCreateBlueprintProviderServiceFamilyBlueprintSetupRoute, + AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceCreateBlueprintProviderServiceFamilyOverridesRoute: + AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceCreateBlueprintProviderServiceFamilyOverridesRoute, + AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceCreateBlueprintProviderServiceFamilyServiceInformationRoute: + AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceCreateBlueprintProviderServiceFamilyServiceInformationRoute, AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceCreateBlueprintProviderServiceFamilySummaryRoute: AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceCreateBlueprintProviderServiceFamilySummaryRoute, AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceCreateBlueprintProviderServiceFamilyIndexRoute: diff --git a/apps/console/src/routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/create/blueprint/$provider/$serviceFamily/blueprint-setup.tsx b/apps/console/src/routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/create/blueprint/$provider/$serviceFamily/blueprint-setup.tsx new file mode 100644 index 00000000000..42e3ab6ee3e --- /dev/null +++ b/apps/console/src/routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/create/blueprint/$provider/$serviceFamily/blueprint-setup.tsx @@ -0,0 +1,21 @@ +import { createFileRoute } from '@tanstack/react-router' +import { BlueprintManifestFieldsProvider, BlueprintSetupSection } from '@qovery/domains/services/feature' +import { useDocumentTitle } from '@qovery/shared/util-hooks' + +export const Route = createFileRoute( + '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/create/blueprint/$provider/$serviceFamily/blueprint-setup' +)({ + component: RouteComponent, +}) + +function RouteComponent() { + const { serviceFamily } = Route.useParams() + + useDocumentTitle(`${serviceFamily} setup`) + + return ( + + + + ) +} diff --git a/apps/console/src/routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/create/blueprint/$provider/$serviceFamily/index.tsx b/apps/console/src/routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/create/blueprint/$provider/$serviceFamily/index.tsx index 19f146e0386..c4dcbd74e02 100644 --- a/apps/console/src/routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/create/blueprint/$provider/$serviceFamily/index.tsx +++ b/apps/console/src/routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/create/blueprint/$provider/$serviceFamily/index.tsx @@ -1,6 +1,4 @@ -import { createFileRoute } from '@tanstack/react-router' -import { BlueprintConfigurationView } from '@qovery/domains/services/feature' -import { useDocumentTitle } from '@qovery/shared/util-hooks' +import { Navigate, createFileRoute } from '@tanstack/react-router' export const Route = createFileRoute( '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/create/blueprint/$provider/$serviceFamily/' @@ -9,9 +7,13 @@ export const Route = createFileRoute( }) function RouteComponent() { - const { serviceFamily } = Route.useParams() + const { organizationId, projectId, environmentId, provider, serviceFamily } = Route.useParams() - useDocumentTitle(`${serviceFamily} configuration`) - - return + return ( + + ) } diff --git a/apps/console/src/routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/create/blueprint/$provider/$serviceFamily/overrides.tsx b/apps/console/src/routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/create/blueprint/$provider/$serviceFamily/overrides.tsx new file mode 100644 index 00000000000..2007ee62850 --- /dev/null +++ b/apps/console/src/routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/create/blueprint/$provider/$serviceFamily/overrides.tsx @@ -0,0 +1,24 @@ +import { createFileRoute } from '@tanstack/react-router' +import { + BlueprintManifestFieldsProvider, + BlueprintOverridesConfigurationSection, +} from '@qovery/domains/services/feature' +import { useDocumentTitle } from '@qovery/shared/util-hooks' + +export const Route = createFileRoute( + '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/create/blueprint/$provider/$serviceFamily/overrides' +)({ + component: RouteComponent, +}) + +function RouteComponent() { + const { serviceFamily } = Route.useParams() + + useDocumentTitle(`${serviceFamily} overrides`) + + return ( + + + + ) +} diff --git a/apps/console/src/routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/create/blueprint/$provider/$serviceFamily/service-information.tsx b/apps/console/src/routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/create/blueprint/$provider/$serviceFamily/service-information.tsx new file mode 100644 index 00000000000..e3cdb5e4c56 --- /dev/null +++ b/apps/console/src/routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/create/blueprint/$provider/$serviceFamily/service-information.tsx @@ -0,0 +1,17 @@ +import { createFileRoute } from '@tanstack/react-router' +import { BlueprintServiceInformationSection } from '@qovery/domains/services/feature' +import { useDocumentTitle } from '@qovery/shared/util-hooks' + +export const Route = createFileRoute( + '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/create/blueprint/$provider/$serviceFamily/service-information' +)({ + component: RouteComponent, +}) + +function RouteComponent() { + const { serviceFamily } = Route.useParams() + + useDocumentTitle(`${serviceFamily} configuration`) + + return +} diff --git a/apps/console/src/routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/create/blueprint/$provider/$serviceFamily/summary.tsx b/apps/console/src/routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/create/blueprint/$provider/$serviceFamily/summary.tsx index f4a3d97fa97..7803e7fb131 100644 --- a/apps/console/src/routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/create/blueprint/$provider/$serviceFamily/summary.tsx +++ b/apps/console/src/routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/create/blueprint/$provider/$serviceFamily/summary.tsx @@ -1,5 +1,5 @@ import { createFileRoute } from '@tanstack/react-router' -import { BlueprintStepSummary } from '@qovery/domains/services/feature' +import { BlueprintManifestFieldsProvider, BlueprintStepSummary } from '@qovery/domains/services/feature' import { useDocumentTitle } from '@qovery/shared/util-hooks' export const Route = createFileRoute( @@ -11,5 +11,9 @@ export const Route = createFileRoute( function RouteComponent() { useDocumentTitle('Summary - Create service from blueprint') - return + return ( + + + + ) } 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 95586e965f9..2e48a837e0d 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 @@ -302,6 +302,13 @@ export const services = createQueryKeys('services', { return response.data.results }, }), + blueprintUpdate: ({ blueprintId }: { blueprintId: string }) => ({ + queryKey: [blueprintId], + async queryFn() { + const response = await blueprintApi.checkBlueprintUpdate(blueprintId) + return response.data + }, + }), deploymentStatus: (environmentId: string, serviceId: string) => ({ queryKey: [environmentId, serviceId], // NOTE: Value is set by WebSocket diff --git a/libs/domains/services/feature/src/index.ts b/libs/domains/services/feature/src/index.ts index 438cb9322fe..5bdab058317 100644 --- a/libs/domains/services/feature/src/index.ts +++ b/libs/domains/services/feature/src/index.ts @@ -33,6 +33,7 @@ export * from './lib/hooks/use-argocd-manifest/use-argocd-manifest' 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-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' diff --git a/libs/domains/services/feature/src/lib/hooks/use-blueprint-catalog-service-manifest/use-blueprint-catalog-service-manifest.ts b/libs/domains/services/feature/src/lib/hooks/use-blueprint-catalog-service-manifest/use-blueprint-catalog-service-manifest.ts index 1e80f409152..dc7d096c69f 100644 --- a/libs/domains/services/feature/src/lib/hooks/use-blueprint-catalog-service-manifest/use-blueprint-catalog-service-manifest.ts +++ b/libs/domains/services/feature/src/lib/hooks/use-blueprint-catalog-service-manifest/use-blueprint-catalog-service-manifest.ts @@ -1,4 +1,5 @@ -import { useQuery } from '@tanstack/react-query' +import { useQuery, useQueryClient } from '@tanstack/react-query' +import { useCallback } from 'react' import { queries } from '@qovery/state/util-queries' export interface UseBlueprintCatalogServiceManifestProps { @@ -39,4 +40,30 @@ export function useBlueprintCatalogServiceManifest({ }) } +export function usePrefetchBlueprintCatalogServiceManifest({ + organizationId, + provider, + serviceFamily, + serviceVersion, + environmentId = '', +}: UseBlueprintCatalogServiceManifestProps) { + const queryClient = useQueryClient() + + return useCallback(async () => { + if (!organizationId || !provider || !serviceFamily || !serviceVersion || !environmentId) { + return + } + + await queryClient.prefetchQuery( + queries.services.blueprintCatalogServiceManifest({ + organizationId, + provider, + serviceFamily, + serviceVersion, + environmentId, + }) + ) + }, [environmentId, organizationId, provider, queryClient, serviceFamily, serviceVersion]) +} + export default useBlueprintCatalogServiceManifest diff --git a/libs/domains/services/feature/src/lib/hooks/use-blueprint-update/use-blueprint-update.ts b/libs/domains/services/feature/src/lib/hooks/use-blueprint-update/use-blueprint-update.ts new file mode 100644 index 00000000000..990d196314a --- /dev/null +++ b/libs/domains/services/feature/src/lib/hooks/use-blueprint-update/use-blueprint-update.ts @@ -0,0 +1,18 @@ +import { useQuery } from '@tanstack/react-query' +import { queries } from '@qovery/state/util-queries' + +export interface UseBlueprintUpdateProps { + blueprintId: string + enabled?: boolean + suspense?: boolean +} + +export function useBlueprintUpdate({ blueprintId, enabled = true, suspense = false }: UseBlueprintUpdateProps) { + return useQuery({ + ...queries.services.blueprintUpdate({ blueprintId }), + enabled, + suspense, + }) +} + +export default useBlueprintUpdate 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 451ba0b4e41..d3cc7b1348c 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 @@ -1,7 +1,8 @@ -import { useNavigate, useSearch } from '@tanstack/react-router' -import { useEffect, useState } from 'react' -import { type ServiceCreateSection } from '@qovery/shared/router' -import { Button, FunnelFlowBody, Icon, InputText } from '@qovery/shared/ui' +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, @@ -10,52 +11,43 @@ import { getFieldValidationError, getStringFieldValue, isFieldValid, + 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 { OverridesSection } from './blueprint-creation-components/overrides-section/overrides-section' +import { OverridesSectionCard } from './blueprint-creation-components/overrides-section-card/overrides-section-card' -function isBlueprintConfigurationSection(value: unknown): value is ServiceCreateSection { - return value === 'service-information' || value === 'blueprint-setup' || value === 'overrides' +type BlueprintConfigurationSection = 'service-information' | 'blueprint-setup' | 'overrides' + +interface BlueprintConfigurationViewProps { + currentSection: BlueprintConfigurationSection +} + +export function BlueprintServiceInformationSection() { + return +} + +export function BlueprintSetupSection() { + return } -export function BlueprintConfigurationView() { +export function BlueprintOverridesConfigurationSection() { + return +} + +export function BlueprintConfigurationView({ currentSection }: BlueprintConfigurationViewProps) { const navigate = useNavigate() - const search = useSearch({ strict: false }) as { section?: unknown } - const { - blueprint, - creationFlowUrl, - form, - optionalBlueprintFields, - overridableContextBlueprintFields, - onViewDetails, - requiredBlueprintFields, - serviceVersion, - setCurrentStep, - } = useBlueprintCreateContext() - const initialSection = isBlueprintConfigurationSection(search.section) ? search.section : 'service-information' - const [currentSection, setCurrentSection] = useState(initialSection) - const serviceName = form.watch('serviceName') - const blueprintFieldValues = form.watch('fields') - const hasOverrideFields = optionalBlueprintFields.length > 0 || overridableContextBlueprintFields.length > 0 - const isServiceInformationValid = Boolean(serviceName.trim()) - const isBlueprintSetupValid = requiredBlueprintFields.every((field) => - isFieldValid(field, blueprintFieldValues[field.name]) - ) - const updateFieldValue = (name: string, value: BlueprintFieldValue) => { - form.setValue(getBlueprintFieldPath(name), value, { shouldDirty: true, shouldValidate: true }) + const { blueprint, creationFlowUrl, onViewDetails, setCurrentStep } = useBlueprintCreateContext() + const isServiceInformationValid = useIsServiceInformationValid() + const navigateToSection = (section: BlueprintConfigurationSection) => { + navigate({ to: `${creationFlowUrl}/${section}` }) } useEffect(() => { setCurrentStep(1) }, [setCurrentStep]) - useEffect(() => { - if (isBlueprintConfigurationSection(search.section)) { - setCurrentSection(search.section) - } - }, [search.section]) - return (
@@ -75,30 +67,10 @@ export function BlueprintConfigurationView() { completed={currentSection !== 'service-information'} iconName="circle-info" title="Service information" - onClick={() => setCurrentSection('service-information')} + onClick={() => navigateToSection('service-information')} > {currentSection === 'service-information' && ( - <> - form.setValue('serviceName', event.currentTarget.value)} - autoFocus - /> - - - + navigateToSection('blueprint-setup')} /> )} @@ -108,79 +80,217 @@ export function BlueprintConfigurationView() { disabled={!isServiceInformationValid} iconName="chart-bullet" title="Blueprint setup" - onClick={() => setCurrentSection('blueprint-setup')} + onClick={() => navigateToSection('blueprint-setup')} > - {currentSection === 'blueprint-setup' && ( - <> - {requiredBlueprintFields.map((field, index) => ( - updateFieldValue(field.name, value)} - /> - ))} - - - )} + {currentSection === 'blueprint-setup' && } - setCurrentSection('overrides')} - > - {hasOverrideFields && ( - <> - {optionalBlueprintFields.map((field, index) => ( - updateFieldValue(field.name, value)} - /> - ))} - {overridableContextBlueprintFields.map((field, index) => ( - updateFieldValue(field.name, event.currentTarget.value)} - /> - ))} - - )} - + + {currentSection === 'service-information' ? ( + navigateToSection('overrides')} /> + ) : ( + navigateToSection('overrides')} /> + )}
-
- -
+ {currentSection === 'service-information' ? ( + + ) : ( + + )} ) } + +interface ServiceInformationSectionContentProps { + onContinue: () => void +} + +function ServiceInformationSectionContent({ onContinue }: ServiceInformationSectionContentProps) { + const { blueprint, form, organizationId, serviceVersion } = useBlueprintCreateContext() + const { environmentId = '', serviceFamily = blueprint.serviceFamily ?? '' } = useParams({ strict: false }) + const prefetchBlueprintManifestFields = usePrefetchBlueprintCatalogServiceManifest({ + organizationId, + provider: blueprint.provider, + serviceFamily, + serviceVersion, + environmentId, + }) + const [isLoadingBlueprintSetup, setIsLoadingBlueprintSetup] = useState(false) + const serviceName = form.watch('serviceName') + const versionTag = form.watch('versionTag') + const isServiceInformationValid = useIsServiceInformationValid() + const blueprintVersionOptions = useMemo( + () => + sortBlueprintMajorVersions(blueprint.majorVersions).map((majorVersion) => ({ + label: majorVersion.serviceVersion, + value: majorVersion.latestTag, + })), + [blueprint.majorVersions] + ) + const handleContinue = async () => { + setIsLoadingBlueprintSetup(true) + await prefetchBlueprintManifestFields().catch(() => undefined) + onContinue() + } + + return ( + <> + form.setValue('serviceName', event.currentTarget.value)} + autoFocus + /> + {blueprintVersionOptions.length > 1 ? ( + + form.setValue('versionTag', Array.isArray(value) ? value[0] : value, { shouldDirty: true }) + } + isSearchable={blueprintVersionOptions.length > 6} + /> + ) : ( + + )} + + + ) +} + +function BlueprintSetupSectionContent() { + const { form } = useBlueprintCreateContext() + const { requiredBlueprintFields } = useBlueprintManifestFields() + const blueprintFieldValues = form.watch('fields') + const updateFieldValue = (name: string, value: BlueprintFieldValue) => { + form.setValue(getBlueprintFieldPath(name), value, { shouldDirty: true, shouldValidate: true }) + } + + return ( + <> + {requiredBlueprintFields.map((field, index) => ( + updateFieldValue(field.name, value)} + /> + ))} + + ) +} + +interface BlueprintOverridesSectionProps { + currentSection: Exclude + onClick: () => void +} + +function BlueprintOverridesSection({ currentSection, onClick }: BlueprintOverridesSectionProps) { + const { optionalBlueprintFields, overridableContextBlueprintFields } = useBlueprintManifestFields() + const isBlueprintSetupValid = useIsBlueprintSetupValid() + const hasOverrideFields = optionalBlueprintFields.length > 0 || overridableContextBlueprintFields.length > 0 + + return ( + + {currentSection === 'overrides' && hasOverrideFields && } + + ) +} + +function OverridesSectionContent() { + const { form } = useBlueprintCreateContext() + const { optionalBlueprintFields, overridableContextBlueprintFields } = useBlueprintManifestFields() + const blueprintFieldValues = form.watch('fields') + const updateFieldValue = (name: string, value: BlueprintFieldValue) => { + form.setValue(getBlueprintFieldPath(name), value, { shouldDirty: true, shouldValidate: true }) + } + + return ( + <> + {optionalBlueprintFields.map((field, index) => ( + updateFieldValue(field.name, value)} + /> + ))} + {overridableContextBlueprintFields.map((field, index) => ( + updateFieldValue(field.name, event.currentTarget.value)} + /> + ))} + + ) +} + +function ConfirmBlueprintCreationFooter() { + const navigate = useNavigate() + const { creationFlowUrl } = useBlueprintCreateContext() + const isServiceInformationValid = useIsServiceInformationValid() + const isBlueprintSetupValid = useIsBlueprintSetupValid() + + return ( +
+ +
+ ) +} + +function DisabledConfirmBlueprintCreationFooter() { + return ( +
+ +
+ ) +} + +function useIsBlueprintSetupValid() { + const { form } = useBlueprintCreateContext() + const { requiredBlueprintFields } = useBlueprintManifestFields() + const blueprintFieldValues = form.watch('fields') + + return requiredBlueprintFields.every((field) => isFieldValid(field, blueprintFieldValues[field.name])) +} + +function useIsServiceInformationValid() { + const { form } = useBlueprintCreateContext() + const serviceName = form.watch('serviceName') + + return Boolean(serviceName.trim()) +} diff --git a/libs/domains/services/feature/src/lib/service-creation-flow/blueprint/blueprint-configuration-view/blueprint-creation-components/overrides-section/overrides-section.tsx b/libs/domains/services/feature/src/lib/service-creation-flow/blueprint/blueprint-configuration-view/blueprint-creation-components/overrides-section-card/overrides-section-card.tsx similarity index 89% rename from libs/domains/services/feature/src/lib/service-creation-flow/blueprint/blueprint-configuration-view/blueprint-creation-components/overrides-section/overrides-section.tsx rename to libs/domains/services/feature/src/lib/service-creation-flow/blueprint/blueprint-configuration-view/blueprint-creation-components/overrides-section-card/overrides-section-card.tsx index 3a28626c809..bb026ebc65b 100644 --- a/libs/domains/services/feature/src/lib/service-creation-flow/blueprint/blueprint-configuration-view/blueprint-creation-components/overrides-section/overrides-section.tsx +++ b/libs/domains/services/feature/src/lib/service-creation-flow/blueprint/blueprint-configuration-view/blueprint-creation-components/overrides-section-card/overrides-section-card.tsx @@ -2,14 +2,14 @@ import { type ReactNode } from 'react' import { Heading, Icon, Section } from '@qovery/shared/ui' import { BlueprintSection } from '../blueprint-section/blueprint-section' -export interface OverridesSectionProps { +export interface OverridesSectionCardProps { active: boolean children?: ReactNode disabled?: boolean onClick: () => void } -export function OverridesSection({ active, children, disabled = false, onClick }: OverridesSectionProps) { +export function OverridesSectionCard({ active, children, disabled = false, onClick }: OverridesSectionCardProps) { if (!active) { return ( onViewDetails: () => void serviceVersion: string - requiredBlueprintFields: BlueprintManifestVariableField[] - optionalBlueprintFields: BlueprintManifestVariableField[] - overridableContextBlueprintFields: OverridableBlueprintManifestContextVariableField[] } export interface BlueprintCreationFlowProps extends PropsWithChildren { diff --git a/libs/domains/services/feature/src/lib/service-creation-flow/blueprint/blueprint-creation-flow.spec.tsx b/libs/domains/services/feature/src/lib/service-creation-flow/blueprint/blueprint-creation-flow.spec.tsx index f790615b34b..199389a7114 100644 --- a/libs/domains/services/feature/src/lib/service-creation-flow/blueprint/blueprint-creation-flow.spec.tsx +++ b/libs/domains/services/feature/src/lib/service-creation-flow/blueprint/blueprint-creation-flow.spec.tsx @@ -1,18 +1,22 @@ import { useParams } from '@tanstack/react-router' import { act, within } from '@testing-library/react' import type { BlueprintItem, BlueprintManifestResponseResultsInner } from 'qovery-typescript-axios' -import { type ReactNode, useEffect, useState } from 'react' +import { type ReactNode, useEffect, useLayoutEffect, useState } from 'react' +import selectEvent from 'react-select-event' import { renderWithProviders, screen, waitFor } from '@qovery/shared/util-tests' import { - BlueprintConfigurationView, BlueprintCreationFlow, + BlueprintManifestFieldsProvider, + BlueprintOverridesConfigurationSection, + BlueprintServiceInformationSection, + BlueprintSetupSection, BlueprintStepSummary, useBlueprintCreateContext, } from './blueprint-creation-flow' const mockNavigate = jest.fn() -const mockUseSearch = jest.fn() const mockUseBlueprintCatalogServiceManifest = jest.fn() +const mockPrefetchBlueprintManifestFields = jest.fn() const mockUseBlueprintCatalogServiceReadme = jest.fn() const mockUseBlueprintServiceCreatedSocket = jest.fn() const mockCreateBlueprint = jest.fn() @@ -22,7 +26,6 @@ jest.mock('@tanstack/react-router', () => ({ ...jest.requireActual('@tanstack/react-router'), useNavigate: () => mockNavigate, useParams: jest.fn(), - useSearch: () => mockUseSearch(), })) jest.mock('@qovery/shared/ui', () => { @@ -43,6 +46,7 @@ jest.mock('@qovery/shared/ui', () => { jest.mock('../../hooks/use-blueprint-catalog-service-manifest/use-blueprint-catalog-service-manifest', () => ({ useBlueprintCatalogServiceManifest: (props: unknown) => mockUseBlueprintCatalogServiceManifest(props), + usePrefetchBlueprintCatalogServiceManifest: () => mockPrefetchBlueprintManifestFields, })) jest.mock('../../hooks/use-blueprint-catalog-service-readme/use-blueprint-catalog-service-readme', () => ({ @@ -120,25 +124,50 @@ function renderBlueprintFlow(children: JSX.Element) { function FillFormValues({ children }: { children: JSX.Element }) { const { form } = useBlueprintCreateContext() - - useEffect(() => { - form.setValue('serviceName', 'custom-postgres') - form.setValue('fields', { - db_name: 'production', - db_username: 'postgres', - db_password: 'super-secret', - skip_final_snapshot: true, + const [ready, setReady] = useState(false) + + useLayoutEffect(() => { + form.reset({ + ...form.getValues(), + serviceName: 'custom-postgres', + loadedVersionTag: 'aws/postgres/17/1.0.0', + fields: { + db_name: 'production', + db_username: 'postgres', + db_password: 'super-secret', + skip_final_snapshot: true, + }, }) + setReady(true) }, [form]) + if (!ready) { + return null + } + return children } -function BlueprintFlowRouteHarness() { - const [step, setStep] = useState<'configuration' | 'summary'>('configuration') +function WithBlueprintManifestFields({ children }: { children: JSX.Element }) { + return {children} +} + +type BlueprintRouteStep = 'service-information' | 'blueprint-setup' | 'overrides' | 'summary' + +function BlueprintFlowRouteHarness({ flowBlueprint = blueprint }: { flowBlueprint?: BlueprintItem }) { + const [step, setStep] = useState('service-information') useEffect(() => { mockNavigate.mockImplementation((options: { to?: string }) => { + if (options.to?.endsWith('/service-information')) { + setStep('service-information') + } + if (options.to?.endsWith('/blueprint-setup')) { + setStep('blueprint-setup') + } + if (options.to?.endsWith('/overrides')) { + setStep('overrides') + } if (options.to?.endsWith('/summary')) { setStep('summary') } @@ -146,8 +175,23 @@ function BlueprintFlowRouteHarness() { }, []) return ( - - {step === 'configuration' ? : } + + {step === 'service-information' && } + {step === 'blueprint-setup' && ( + + + + )} + {step === 'overrides' && ( + + + + )} + {step === 'summary' && ( + + + + )} ) } @@ -164,7 +208,10 @@ describe('BlueprintCreationFlow', () => { provider: 'AWS', serviceFamily: 'postgres', }) - mockUseBlueprintCatalogServiceManifest.mockReturnValue({ data: manifestFields }) + mockUseBlueprintCatalogServiceManifest.mockReturnValue({ + data: manifestFields, + }) + mockPrefetchBlueprintManifestFields.mockResolvedValue(undefined) mockUseBlueprintCatalogServiceReadme.mockReturnValue({ data: { content: '# AWS RDS PostgreSQL\n\nBlueprint documentation', @@ -172,13 +219,12 @@ describe('BlueprintCreationFlow', () => { }, }) mockCreateBlueprint.mockResolvedValue({ environment_id: 'env-1' }) - mockUseSearch.mockReturnValue({}) }) it('should open the blueprint details drawer from the configuration header', async () => { jest.useFakeTimers() - const { userEvent } = renderBlueprintFlow() + const { userEvent } = renderBlueprintFlow() await userEvent.click(screen.getByRole('button', { name: 'AWS RDS PostgreSQL' })) @@ -191,136 +237,94 @@ describe('BlueprintCreationFlow', () => { expect(within(dialog).queryByRole('link', { name: 'Deploy blueprint' })).not.toBeInTheDocument() }) - it('should navigate to the blueprint summary from the confirm blueprint configuration button', async () => { + it('should focus the first blueprint setup field after continuing from service information', async () => { jest.useFakeTimers() - const { userEvent } = renderBlueprintFlow() + const { userEvent } = renderWithProviders() + await userEvent.type(screen.getByLabelText('Service name'), 'custom-postgres') await userEvent.click(screen.getByRole('button', { name: /continue/i })) - await userEvent.type(await screen.findByLabelText('Db name'), 'production') - await userEvent.type(screen.getByLabelText('Db username'), 'postgres') - await userEvent.type(screen.getByLabelText('Db password'), 'super-secret') - await userEvent.click(screen.getByRole('button', { name: /continue/i })) - await userEvent.click(screen.getByRole('button', { name: /confirm blueprint configuration/i })) - expect(mockNavigate).toHaveBeenCalledWith({ - to: '/organization/org-1/project/proj-1/environment/env-1/service/create/blueprint/AWS/postgres/summary', - }) + expect(await screen.findByLabelText('Db name')).toHaveFocus() }) - it('should focus the first blueprint setup field after continuing from service information', async () => { - jest.useFakeTimers() + it('should prefetch blueprint setup fields before navigating from service information', async () => { + mockPrefetchBlueprintManifestFields.mockImplementation(async () => { + expect(mockNavigate).not.toHaveBeenCalledWith({ + to: '/organization/org-1/project/proj-1/environment/env-1/service/create/blueprint/AWS/postgres/blueprint-setup', + }) + }) - const { userEvent } = renderBlueprintFlow() + const { userEvent } = renderWithProviders() - await userEvent.type(screen.getByLabelText('Service name'), 'custom-postgres') await userEvent.click(screen.getByRole('button', { name: /continue/i })) - expect(await screen.findByLabelText('Db name')).toHaveFocus() + expect(mockPrefetchBlueprintManifestFields).toHaveBeenCalled() + expect(mockNavigate).toHaveBeenCalledWith({ + to: '/organization/org-1/project/proj-1/environment/env-1/service/create/blueprint/AWS/postgres/blueprint-setup', + }) }) - it('should not load manifest fields when a blueprint has no service family', () => { + it('should not load manifest fields from the service information route', () => { renderWithProviders( - + ) - expect(mockUseBlueprintCatalogServiceManifest).toHaveBeenCalledWith( - expect.objectContaining({ - provider: 'AWS', - serviceFamily: '', - enabled: false, - }) - ) + expect(mockUseBlueprintCatalogServiceManifest).not.toHaveBeenCalled() }) - it('should focus the first overrides field after continuing from blueprint setup', async () => { + it('should focus the first overrides field after opening overrides from blueprint setup', async () => { jest.useFakeTimers() - const { userEvent } = renderBlueprintFlow() + const { userEvent } = renderWithProviders() await userEvent.click(screen.getByRole('button', { name: /continue/i })) await userEvent.type(await screen.findByLabelText('Db name'), 'production') await userEvent.type(screen.getByLabelText('Db username'), 'postgres') await userEvent.type(screen.getByLabelText('Db password'), 'super-secret') - await userEvent.click(screen.getByRole('button', { name: /continue/i })) + await userEvent.click(screen.getByRole('button', { name: /overrides/i })) expect(await screen.findByRole('checkbox', { name: 'Skip final snapshot' })).toHaveFocus() }) - it('should open the requested configuration section from route search', async () => { - mockUseSearch.mockReturnValue({ section: 'overrides' }) - - renderBlueprintFlow() - - expect(await screen.findByText(/Use overrides to customize how your service is built or run/)).toBeInTheDocument() - expect(screen.getByRole('checkbox', { name: 'Skip final snapshot' })).toBeInTheDocument() - }) - - it('should fallback to service information when the route search section is invalid', () => { - mockUseSearch.mockReturnValue({ section: 'unknown-section' }) - - renderBlueprintFlow() - - expect(screen.getByLabelText('Service name')).toBeInTheDocument() - expect(screen.queryByLabelText('Db name')).not.toBeInTheDocument() - expect(screen.queryByText(/Use overrides to customize how your service is built or run/)).not.toBeInTheDocument() - }) - - it('should update the current section when the route search section changes to a valid section', async () => { - mockUseSearch.mockReturnValue({}) - - const { rerender } = renderBlueprintFlow() - - expect(screen.getByLabelText('Service name')).toBeInTheDocument() - - mockUseSearch.mockReturnValue({ section: 'blueprint-setup' }) - rerender( - - - - ) - - expect(await screen.findByLabelText('Db name')).toBeInTheDocument() - expect(screen.queryByLabelText('Service name')).not.toBeInTheDocument() - }) - it('should navigate to the matching configuration section from summary edit buttons', async () => { jest.useFakeTimers() const { userEvent } = renderBlueprintFlow( - - - + + + + + ) await screen.findByText(/custom-postgres/) await userEvent.click(screen.getByRole('button', { name: 'Edit service information' })) expect(mockNavigate).toHaveBeenLastCalledWith({ - to: '/organization/org-1/project/proj-1/environment/env-1/service/create/blueprint/AWS/postgres', - search: { section: 'service-information' }, + to: '/organization/org-1/project/proj-1/environment/env-1/service/create/blueprint/AWS/postgres/service-information', }) await userEvent.click(screen.getByRole('button', { name: 'Edit blueprint setup' })) expect(mockNavigate).toHaveBeenLastCalledWith({ - to: '/organization/org-1/project/proj-1/environment/env-1/service/create/blueprint/AWS/postgres', - search: { section: 'blueprint-setup' }, + to: '/organization/org-1/project/proj-1/environment/env-1/service/create/blueprint/AWS/postgres/blueprint-setup', }) await userEvent.click(screen.getByRole('button', { name: 'Edit overrides' })) expect(mockNavigate).toHaveBeenLastCalledWith({ - to: '/organization/org-1/project/proj-1/environment/env-1/service/create/blueprint/AWS/postgres', - search: { section: 'overrides' }, + to: '/organization/org-1/project/proj-1/environment/env-1/service/create/blueprint/AWS/postgres/overrides', }) }) it('should render the summary with the previously filled blueprint values', async () => { renderBlueprintFlow( - - - + + + + + ) expect(screen.getByText('Ready to create your blueprint service')).toBeInTheDocument() @@ -340,7 +344,6 @@ describe('BlueprintCreationFlow', () => { await userEvent.type(await screen.findByLabelText('Db name'), 'production') await userEvent.type(screen.getByLabelText('Db username'), 'postgres') await userEvent.type(screen.getByLabelText('Db password'), 'super-secret') - await userEvent.click(screen.getByRole('button', { name: /continue/i })) await userEvent.click(screen.getByRole('button', { name: /confirm blueprint configuration/i })) expect(await screen.findByText('Ready to create your blueprint service')).toBeInTheDocument() @@ -349,12 +352,47 @@ describe('BlueprintCreationFlow', () => { expect(screen.getByText(/••••••••/)).toBeInTheDocument() }) + it('should send default override values in the create payload when overrides were not opened', async () => { + jest.useFakeTimers() + + const { userEvent } = renderWithProviders() + + await userEvent.click(screen.getByRole('button', { name: /continue/i })) + await userEvent.type(await screen.findByLabelText('Db name'), 'production') + await userEvent.type(screen.getByLabelText('Db username'), 'postgres') + await userEvent.type(screen.getByLabelText('Db password'), 'super-secret') + await userEvent.click(screen.getByRole('button', { name: /confirm blueprint configuration/i })) + expect(await screen.findByText('Ready to create your blueprint service')).toBeInTheDocument() + + await userEvent.click(screen.getByTestId('button-create')) + + await waitFor(() => { + expect(mockCreateBlueprint).toHaveBeenCalledWith( + expect.objectContaining({ + payload: expect.objectContaining({ + variables: expect.arrayContaining([ + { + name: 'skip_final_snapshot', + value: 'true', + is_secret: false, + }, + ]), + }), + }) + ) + }) + }) + it('should redirect to configuration when summary is opened without required blueprint values', async () => { - renderBlueprintFlow() + renderBlueprintFlow( + + + + ) await waitFor(() => { expect(mockNavigate).toHaveBeenCalledWith({ - to: '/organization/org-1/project/proj-1/environment/env-1/service/create/blueprint/AWS/postgres', + to: '/organization/org-1/project/proj-1/environment/env-1/service/create/blueprint/AWS/postgres/blueprint-setup', }) }) }) @@ -363,9 +401,11 @@ describe('BlueprintCreationFlow', () => { jest.useFakeTimers() const { userEvent } = renderBlueprintFlow( - - - + + + + + ) await screen.findByText(/custom-postgres/) @@ -406,6 +446,56 @@ describe('BlueprintCreationFlow', () => { }) }) + it('should sort blueprint versions and send the selected version tag in the create payload', async () => { + jest.useFakeTimers() + const versionedBlueprint: BlueprintItem = { + ...blueprint, + majorVersions: [ + { serviceVersion: '14', latestTag: 'aws/postgres/14/1.0.0' }, + { serviceVersion: '17', latestTag: 'aws/postgres/17/1.0.0' }, + { serviceVersion: '16', latestTag: 'aws/postgres/16/1.0.0' }, + ], + } + + const { userEvent } = renderWithProviders() + + expect(screen.getByText('17')).toBeInTheDocument() + expect(mockUseBlueprintCatalogServiceManifest).not.toHaveBeenCalled() + + await selectEvent.select(screen.getByLabelText('Blueprint version'), '16') + expect(mockUseBlueprintCatalogServiceManifest).not.toHaveBeenCalled() + + await userEvent.click(screen.getByRole('button', { name: /continue/i })) + await screen.findByLabelText('Db name') + await waitFor(() => + expect(mockUseBlueprintCatalogServiceManifest).toHaveBeenCalledWith( + expect.objectContaining({ + serviceVersion: '16', + suspense: true, + }) + ) + ) + await userEvent.type(await screen.findByLabelText('Db name'), 'production') + await userEvent.type(screen.getByLabelText('Db username'), 'postgres') + await userEvent.type(screen.getByLabelText('Db password'), 'super-secret') + await userEvent.click(screen.getByRole('button', { name: /confirm blueprint configuration/i })) + + expect(await screen.findByText('Ready to create your blueprint service')).toBeInTheDocument() + expect(screen.getByText('16')).toBeInTheDocument() + + await userEvent.click(screen.getByTestId('button-create')) + + await waitFor(() => { + expect(mockCreateBlueprint).toHaveBeenCalledWith( + expect.objectContaining({ + payload: expect.objectContaining({ + tag: 'aws/postgres/16/1.0.0', + }), + }) + ) + }) + }) + it('should start listening for the blueprint service-created event before creating the blueprint', async () => { jest.useFakeTimers() let resolveCreateBlueprint: (value: { environment_id: string }) => void = jest.fn() @@ -417,9 +507,11 @@ describe('BlueprintCreationFlow', () => { ) const { userEvent } = renderBlueprintFlow( - - - + + + + + ) await screen.findByText(/custom-postgres/) @@ -503,9 +595,11 @@ describe('BlueprintCreationFlow', () => { ) const { userEvent } = renderBlueprintFlow( - - - + + + + + ) await screen.findByText(/custom-postgres/) diff --git a/libs/domains/services/feature/src/lib/service-creation-flow/blueprint/blueprint-creation-flow.tsx b/libs/domains/services/feature/src/lib/service-creation-flow/blueprint/blueprint-creation-flow.tsx index e062676bb7f..a234cdaf065 100644 --- a/libs/domains/services/feature/src/lib/service-creation-flow/blueprint/blueprint-creation-flow.tsx +++ b/libs/domains/services/feature/src/lib/service-creation-flow/blueprint/blueprint-creation-flow.tsx @@ -3,19 +3,12 @@ import { useMemo, useState } from 'react' import { FormProvider, useForm } from 'react-hook-form' import { FunnelFlow } from '@qovery/shared/ui' import { BlueprintDetailsPanel } from '../../blueprint-details-panel/blueprint-details-panel' -import { useBlueprintCatalogServiceManifest } from '../../hooks/use-blueprint-catalog-service-manifest/use-blueprint-catalog-service-manifest' import { BlueprintCreateContext, type BlueprintCreateFormData, type BlueprintCreationFlowProps, } from './blueprint-create-context/blueprint-create-context' -import { - getDefaultContextFieldValue, - getDefaultFieldValue, - isOptionalVariableField, - isOverridableContextVariableField, - isRequiredVariableField, -} from './blueprint-creation-utils/blueprint-creation-utils' +import { sortBlueprintMajorVersions } from './blueprint-creation-utils/blueprint-creation-utils' export { BlueprintCreateContext, @@ -24,7 +17,16 @@ export { type BlueprintCreationFlowProps, useBlueprintCreateContext, } from './blueprint-create-context/blueprint-create-context' -export { BlueprintConfigurationView } from './blueprint-configuration-view/blueprint-configuration-view' +export { + BlueprintConfigurationView, + BlueprintOverridesConfigurationSection, + BlueprintServiceInformationSection, + BlueprintSetupSection, +} from './blueprint-configuration-view/blueprint-configuration-view' +export { + BlueprintManifestFieldsProvider, + useBlueprintManifestFields, +} from './blueprint-manifest-context/blueprint-manifest-context' export { BlueprintStepSummary } from './blueprint-step-summary/blueprint-step-summary' export const blueprintCreationSteps: { title: string }[] = [{ title: 'Configuration' }, { title: 'Summary' }] @@ -32,7 +34,25 @@ export const blueprintCreationSteps: { title: string }[] = [{ title: 'Configurat export function BlueprintCreationFlow({ blueprint, children, onExit }: BlueprintCreationFlowProps) { const [currentStep, setCurrentStep] = useState(1) const [selectedBlueprint, setSelectedBlueprint] = useState(null) - const serviceVersion = blueprint.majorVersions[0]?.serviceVersion ?? 'latest' + const sortedBlueprintVersions = useMemo( + () => sortBlueprintMajorVersions(blueprint.majorVersions), + [blueprint.majorVersions] + ) + const defaultBlueprintVersion = sortedBlueprintVersions[0] + const defaultVersionTag = defaultBlueprintVersion?.latestTag ?? '' + const form = useForm({ + defaultValues: { + serviceName: blueprint.name, + versionTag: defaultVersionTag, + fields: {}, + }, + mode: 'onChange', + }) + const selectedVersionTag = form.watch('versionTag') + const selectedBlueprintVersion = + sortedBlueprintVersions.find((majorVersion) => majorVersion.latestTag === selectedVersionTag) ?? + defaultBlueprintVersion + const serviceVersion = selectedBlueprintVersion?.serviceVersion ?? 'latest' const serviceFamily = blueprint.serviceFamily ?? '' const { environmentId = '', @@ -41,45 +61,6 @@ export function BlueprintCreationFlow({ blueprint, children, onExit }: Blueprint provider = blueprint.provider, } = useParams({ strict: false }) const creationFlowUrl = `/organization/${organizationId}/project/${projectId}/environment/${environmentId}/service/create/blueprint/${encodeURIComponent(provider)}/${encodeURIComponent(serviceFamily)}` - const { data: blueprintManifestFields = [] } = useBlueprintCatalogServiceManifest({ - organizationId, - provider: blueprint.provider, - serviceFamily, - serviceVersion, - environmentId, - enabled: Boolean(serviceVersion) && Boolean(serviceFamily) && Boolean(environmentId), - suspense: true, - }) - - const requiredBlueprintFields = useMemo( - () => blueprintManifestFields.filter(isRequiredVariableField), - [blueprintManifestFields] - ) - const optionalBlueprintFields = useMemo( - () => blueprintManifestFields.filter(isOptionalVariableField), - [blueprintManifestFields] - ) - const overridableContextBlueprintFields = useMemo( - () => blueprintManifestFields.filter(isOverridableContextVariableField), - [blueprintManifestFields] - ) - const defaultBlueprintFieldValues = useMemo(() => { - const fieldsWithDefaultValue = [...requiredBlueprintFields, ...optionalBlueprintFields] - - return { - ...Object.fromEntries(fieldsWithDefaultValue.map((field) => [field.name, getDefaultFieldValue(field)])), - ...Object.fromEntries( - overridableContextBlueprintFields.map((field) => [field.name, getDefaultContextFieldValue(field)]) - ), - } - }, [optionalBlueprintFields, overridableContextBlueprintFields, requiredBlueprintFields]) - const form = useForm({ - defaultValues: { - serviceName: blueprint.name, - fields: defaultBlueprintFieldValues, - }, - mode: 'onChange', - }) return ( setSelectedBlueprint(blueprint), serviceVersion, - requiredBlueprintFields, - optionalBlueprintFields, - overridableContextBlueprintFields, }} > diff --git a/libs/domains/services/feature/src/lib/service-creation-flow/blueprint/blueprint-creation-utils/blueprint-creation-utils.spec.ts b/libs/domains/services/feature/src/lib/service-creation-flow/blueprint/blueprint-creation-utils/blueprint-creation-utils.spec.ts index 37ad88af0cd..83a2cf41d72 100644 --- a/libs/domains/services/feature/src/lib/service-creation-flow/blueprint/blueprint-creation-utils/blueprint-creation-utils.spec.ts +++ b/libs/domains/services/feature/src/lib/service-creation-flow/blueprint/blueprint-creation-utils/blueprint-creation-utils.spec.ts @@ -1,4 +1,5 @@ import { + type BlueprintMajorVersion, type BlueprintManifestContextVariableField, type BlueprintManifestResponseResultsInner, type BlueprintManifestVariableField, @@ -22,6 +23,7 @@ import { isOverridableContextVariableField, isRequiredVariableField, isVariableField, + sortBlueprintMajorVersions, } from './blueprint-creation-utils' function createVariableField(overrides: Partial = {}): BlueprintManifestVariableField { @@ -62,6 +64,22 @@ describe('blueprint-creation-utils', () => { }) }) + describe('sortBlueprintMajorVersions', () => { + it('sorts blueprint versions from latest to oldest', () => { + const versions: BlueprintMajorVersion[] = [ + { serviceVersion: '9.6', latestTag: 'aws/postgres/9.6/1.0.0' }, + { serviceVersion: '17', latestTag: 'aws/postgres/17/1.0.0' }, + { serviceVersion: '14', latestTag: 'aws/postgres/14/1.0.0' }, + ] + + expect(sortBlueprintMajorVersions(versions)).toEqual([ + { serviceVersion: '17', latestTag: 'aws/postgres/17/1.0.0' }, + { serviceVersion: '14', latestTag: 'aws/postgres/14/1.0.0' }, + { serviceVersion: '9.6', latestTag: 'aws/postgres/9.6/1.0.0' }, + ]) + }) + }) + describe('getDefaultFieldValue', () => { it('returns the default value for string fields', () => { expect(getDefaultFieldValue(createVariableField({ default_value: 'production' }))).toBe('production') 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 f4b5fc7d753..234434de8f5 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,4 +1,5 @@ import { + type BlueprintMajorVersion, type BlueprintManifestContextVariableField, type BlueprintManifestResponseResultsInner, type BlueprintManifestVariableField, @@ -22,6 +23,15 @@ export function formatFieldLabel(name: string) { return `${label.charAt(0).toUpperCase()}${label.slice(1)}` } +export function sortBlueprintMajorVersions(versions: BlueprintMajorVersion[]) { + return [...versions].sort((a, b) => + b.serviceVersion.localeCompare(a.serviceVersion, undefined, { + numeric: true, + sensitivity: 'base', + }) + ) +} + export function getDefaultFieldValue(field: BlueprintManifestVariableField): BlueprintFieldValue { if (field.type.type === 'bool') return field.default_value === 'true' return field.default_value ?? '' @@ -31,6 +41,20 @@ export function getDefaultContextFieldValue(field: BlueprintManifestContextVaria 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 : '' } 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 new file mode 100644 index 00000000000..f1de20d906d --- /dev/null +++ b/libs/domains/services/feature/src/lib/service-creation-flow/blueprint/blueprint-manifest-context/blueprint-manifest-context.tsx @@ -0,0 +1,88 @@ +import { useParams } from '@tanstack/react-router' +import { + type BlueprintManifestResponseResultsInner, + 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' + +interface BlueprintManifestContextInterface { + blueprintManifestFields: BlueprintManifestResponseResultsInner[] + requiredBlueprintFields: BlueprintManifestVariableField[] + optionalBlueprintFields: BlueprintManifestVariableField[] + overridableContextBlueprintFields: OverridableBlueprintManifestContextVariableField[] +} + +const BlueprintManifestContext = createContext(undefined) + +export function BlueprintManifestFieldsProvider({ children }: PropsWithChildren) { + const { blueprint, form, organizationId, serviceVersion } = useBlueprintCreateContext() + const { environmentId = '', serviceFamily = blueprint.serviceFamily ?? '' } = useParams({ strict: false }) + const versionTag = form.watch('versionTag') + const { data: blueprintManifestFields = [] } = useBlueprintCatalogServiceManifest({ + organizationId, + provider: blueprint.provider, + serviceFamily, + serviceVersion, + environmentId, + suspense: true, + }) + + const requiredBlueprintFields = useMemo( + () => blueprintManifestFields.filter(isRequiredVariableField), + [blueprintManifestFields] + ) + const optionalBlueprintFields = useMemo( + () => blueprintManifestFields.filter(isOptionalVariableField), + [blueprintManifestFields] + ) + const overridableContextBlueprintFields = useMemo( + () => blueprintManifestFields.filter(isOverridableContextVariableField), + [blueprintManifestFields] + ) + + useEffect(() => { + const formValues = form.getValues() + + if (formValues.loadedVersionTag === versionTag) { + return + } + + form.reset({ + ...formValues, + loadedVersionTag: versionTag, + fields: getDefaultBlueprintFieldValues(blueprintManifestFields), + }) + }, [blueprintManifestFields, form, versionTag]) + + return ( + + {children} + + ) +} + +export function useBlueprintManifestFields() { + const blueprintManifestContext = useContext(BlueprintManifestContext) + + if (!blueprintManifestContext) { + throw new Error('useBlueprintManifestFields must be used within a BlueprintManifestFieldsProvider') + } + + return blueprintManifestContext +} 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 8a5328247d3..197a89d65cc 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 @@ -2,7 +2,6 @@ import { useNavigate, useParams } from '@tanstack/react-router' import posthog from 'posthog-js' import { type BlueprintCreateRequest } from 'qovery-typescript-axios' import { useCallback, useEffect, useRef, useState } from 'react' -import { type ServiceCreateSection } from '@qovery/shared/router' 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' @@ -13,22 +12,18 @@ import { getSummaryFieldValue, isFieldValid, } from '../blueprint-creation-utils/blueprint-creation-utils' +import { useBlueprintManifestFields } from '../blueprint-manifest-context/blueprint-manifest-context' + +type BlueprintConfigurationSection = 'service-information' | 'blueprint-setup' | 'overrides' const BLUEPRINT_SERVICE_CREATED_FALLBACK_TIMEOUT_MS = 30_000 export function BlueprintStepSummary() { const navigate = useNavigate() const { organizationId = '', projectId = '', environmentId = '' } = useParams({ strict: false }) - const { - blueprint, - creationFlowUrl, - form, - optionalBlueprintFields, - overridableContextBlueprintFields, - requiredBlueprintFields, - serviceVersion, - setCurrentStep, - } = useBlueprintCreateContext() + const { blueprint, creationFlowUrl, form, serviceVersion, setCurrentStep } = useBlueprintCreateContext() + const { optionalBlueprintFields, overridableContextBlueprintFields, requiredBlueprintFields } = + useBlueprintManifestFields() const [submitMode, setSubmitMode] = useState<'create' | 'create-and-deploy' | null>(null) const [isWaitingForServiceCreated, setIsWaitingForServiceCreated] = useState(false) const [pendingBlueprintCreation, setPendingBlueprintCreation] = useState<{ @@ -45,8 +40,8 @@ export function BlueprintStepSummary() { const blueprintFields = [...variableFields, ...overridableContextBlueprintFields] const isBlueprintSetupValid = requiredBlueprintFields.every((field) => isFieldValid(field, fields[field.name])) - const handleEditSection = (section: ServiceCreateSection) => { - navigate({ to: creationFlowUrl, search: { section } }) + const handleEditSection = (section: BlueprintConfigurationSection) => { + navigate({ to: `${creationFlowUrl}/${section}` }) } const navigateToEnvironmentOverview = useCallback(() => { @@ -109,7 +104,7 @@ export function BlueprintStepSummary() { useEffect(() => { if (!serviceName.trim() || !isBlueprintSetupValid) { - navigate({ to: creationFlowUrl }) + navigate({ to: `${creationFlowUrl}/blueprint-setup` }) return } }, [creationFlowUrl, isBlueprintSetupValid, navigate, serviceName]) @@ -183,7 +178,7 @@ export function BlueprintStepSummary() { deploy: withDeploy, payload: { name: formValues.serviceName, - tag: blueprint.majorVersions[0]?.latestTag ?? '', + tag: formValues.versionTag, icon: blueprint.icon, variables: buildBlueprintVariables(formValues.fields, blueprintFields), }, @@ -283,7 +278,12 @@ export function BlueprintStepSummary() {
-
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 147f91fb1c9..91f595522d7 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 @@ -7,6 +7,7 @@ import { ServiceHeader } from './service-header' const mockCopyToClipboard = jest.fn() const mockGetDatabaseConnectionUri = jest.fn(() => 'postgres://copied-uri') +const mockUseBlueprintUpdate = jest.fn() const services = { 'application-mock': { id: 'ebb84aa8-91c2-40fb-916d-3a158db354b7', @@ -68,6 +69,28 @@ const services = { }, manifest_revision: '313a525a4ad53b37f0b33f81282c52fef5f8e7d8', }, + 'terraform-mock': { + id: 'terraform-mock', + serviceType: 'TERRAFORM', + service_type: 'TERRAFORM', + name: 'aws-s3-bucket', + description: 'Provisioned from AWS S3 Bucket blueprint', + icon_uri: null, + environment: { + id: 'environment-id', + }, + terraform_files_source: { + git: { + git_repository: { + provider: 'GITHUB', + url: 'https://github.com/qovery-blueprints/s3.git', + name: 'qovery-blueprints/s3', + branch: 'main', + }, + }, + }, + blueprint_id: 'blueprint-id', + }, } jest.mock('@tanstack/react-router', () => ({ @@ -252,6 +275,10 @@ jest.mock('../../hooks/use-master-credentials/use-master-credentials', () => ({ }), })) +jest.mock('../../hooks/use-blueprint-update/use-blueprint-update', () => ({ + useBlueprintUpdate: (props: unknown) => mockUseBlueprintUpdate(props), +})) + jest.mock('../../service-access-modal/service-access-modal', () => ({ getDatabaseConnectionUri: () => mockGetDatabaseConnectionUri(), })) @@ -290,10 +317,12 @@ describe('ServiceHeader', () => { beforeEach(() => { jest.clearAllMocks() mockGetDatabaseConnectionUri.mockReturnValue('postgres://copied-uri') + mockUseBlueprintUpdate.mockReturnValue({ data: undefined }) }) - const renderServiceHeader = (serviceId: 'application-mock' | 'database-mock' | 'job-mock' | 'argocd-mock') => - renderWithProviders() + const renderServiceHeader = ( + serviceId: 'application-mock' | 'database-mock' | 'job-mock' | 'argocd-mock' | 'terraform-mock' + ) => renderWithProviders() it('renders application details and git metadata', () => { renderServiceHeader('application-mock') @@ -341,4 +370,51 @@ describe('ServiceHeader', () => { expect(screen.getByText('main')).toBeInTheDocument() expect(screen.getByRole('button', { name: /313a525/i })).toBeInTheDocument() }) + + it('renders an up to date badge for a current blueprint service', () => { + mockUseBlueprintUpdate.mockReturnValue({ + data: { + is_up_to_date: true, + latest_tag: 'aws/s3/1.0.0', + }, + }) + + renderServiceHeader('terraform-mock') + + expect(mockUseBlueprintUpdate).toHaveBeenCalledWith({ blueprintId: 'blueprint-id', suspense: true }) + expect(screen.getByText('Up to date')).toBeInTheDocument() + expect(screen.queryByText('Update available')).not.toBeInTheDocument() + }) + + it('renders an update available badge for an outdated blueprint service', () => { + mockUseBlueprintUpdate.mockReturnValue({ + data: { + is_up_to_date: false, + latest_tag: 'aws/s3/2.0.0', + }, + }) + + renderServiceHeader('terraform-mock') + + expect(screen.getByText('Update available')).toBeInTheDocument() + expect(screen.queryByText('Up to date')).not.toBeInTheDocument() + }) + + it('renders a skeleton while the blueprint update badge is loading', () => { + mockUseBlueprintUpdate.mockImplementation(() => { + throw new Promise(() => undefined) + }) + + renderServiceHeader('terraform-mock') + + expect(screen.getByRole('generic', { busy: true })).toBeInTheDocument() + }) + + it('does not check blueprint update availability for non-blueprint services', () => { + renderServiceHeader('application-mock') + + expect(mockUseBlueprintUpdate).not.toHaveBeenCalled() + expect(screen.queryByText('Up to date')).not.toBeInTheDocument() + expect(screen.queryByText('Update available')).not.toBeInTheDocument() + }) }) 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 4e8361f11d1..a8f01a93036 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,6 @@ import { Link, useParams } from '@tanstack/react-router' import { type ApplicationGitRepository, type Credentials, type Environment } from 'qovery-typescript-axios' +import { Suspense } from 'react' import { P, match } from 'ts-pattern' import { ClusterAvatar, @@ -23,6 +24,7 @@ import { ExternalLink, Heading, Icon, + Skeleton, Tooltip, Truncate, toast, @@ -32,6 +34,7 @@ import { useCopyToClipboard } from '@qovery/shared/util-hooks' import { containerRegistryKindToIcon, upperCaseFirstLetter } from '@qovery/shared/util-js' import { ArgoCdServiceActions } from '../../argocd-service-actions/argocd-service-actions' import AutoDeployBadge from '../../auto-deploy-badge/auto-deploy-badge' +import { useBlueprintUpdate } from '../../hooks/use-blueprint-update/use-blueprint-update' import { useMasterCredentials } from '../../hooks/use-master-credentials/use-master-credentials' import { getDatabaseConnectionUri } from '../../service-access-modal/service-access-modal' import { ServiceActions } from '../../service-actions/service-actions' @@ -156,6 +159,32 @@ interface ServiceHeaderMetadataProps { service: AnyService } +function BlueprintUpdateBadgeSkeleton() { + return +} + +function BlueprintUpdateBadge({ blueprintId }: { blueprintId: string }) { + const { data: blueprintUpdate } = useBlueprintUpdate({ blueprintId, suspense: true }) + + if (!blueprintUpdate) { + return null + } + + return blueprintUpdate.is_up_to_date ? ( + + + Up to date + + ) : ( + + + + Update available + + + ) +} + function ServiceHeaderMetadata({ service }: ServiceHeaderMetadataProps) { const { organizationId = '', projectId = '', environmentId = '', serviceId = '' } = useParams({ strict: false }) const { data: masterCredentials } = useMasterCredentials({ @@ -163,6 +192,7 @@ function ServiceHeaderMetadata({ service }: ServiceHeaderMetadataProps) { serviceType: service?.service_type, }) const [, copyToClipboard] = useCopyToClipboard() + const blueprintId = 'blueprint_id' in service ? service.blueprint_id : undefined const containerImage = match(service) .with({ serviceType: ServiceTypeEnum.JOB, source: P.when(isJobContainerSource) }, ({ source }) => source.image) @@ -293,6 +323,11 @@ function ServiceHeaderMetadata({ service }: ServiceHeaderMetadataProps) { )} + {blueprintId && ( + }> + + + )} {databaseSource && ( <> diff --git a/libs/shared/router/src/lib/router-types/service-container-create-params.ts b/libs/shared/router/src/lib/router-types/service-container-create-params.ts index 941af759c58..1d31693c9b9 100644 --- a/libs/shared/router/src/lib/router-types/service-container-create-params.ts +++ b/libs/shared/router/src/lib/router-types/service-container-create-params.ts @@ -1,16 +1,11 @@ import { z } from 'zod' const serviceCreateSourceEnum = z.enum(['application', 'container']) -const serviceCreateSectionEnum = z.enum(['service-information', 'blueprint-setup', 'overrides']) - -export type ServiceCreateSection = z.infer export const serviceCreateParamsSchema = z.object({ source: serviceCreateSourceEnum.optional(), template: z.string().optional(), option: z.string().optional(), - // Used by blueprint service creation to reopen a specific configuration section from the summary. - section: serviceCreateSectionEnum.optional(), }) export type ServiceCreateParams = z.infer From 22b54920b1eacf5d33b1311c2b8eb3210a7190f1 Mon Sep 17 00:00:00 2001 From: Melvin Zottola <37779145+mzottola@users.noreply.github.com> Date: Mon, 6 Jul 2026 10:15:25 +0200 Subject: [PATCH 11/15] feat(sma): Add banner info for secret manager access plan (#2797) feat(sma) Add banner info for secret manager access plan --- .../cluster/$clusterId/settings/addons.tsx | 22 ++++++++++++++++++- .../step-addons/step-addons.tsx | 11 ++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/apps/console/src/routes/_authenticated/organization/$organizationId/cluster/$clusterId/settings/addons.tsx b/apps/console/src/routes/_authenticated/organization/$organizationId/cluster/$clusterId/settings/addons.tsx index 6aa368ffb1a..bd8e8b58c87 100644 --- a/apps/console/src/routes/_authenticated/organization/$organizationId/cluster/$clusterId/settings/addons.tsx +++ b/apps/console/src/routes/_authenticated/organization/$organizationId/cluster/$clusterId/settings/addons.tsx @@ -15,7 +15,17 @@ import { useEditCluster, } from '@qovery/domains/clusters/feature' import { SettingsHeading } from '@qovery/shared/console-shared' -import { Badge, Button, DropdownMenu, Icon, Section, Tooltip, useModal, useModalConfirmation } from '@qovery/shared/ui' +import { + Badge, + Button, + Callout, + DropdownMenu, + Icon, + Section, + Tooltip, + useModal, + useModalConfirmation, +} from '@qovery/shared/ui' import { useDocumentTitle, useSupportChat } from '@qovery/shared/util-hooks' const SECRET_MANAGER_EARLY_ACCESS_FORM_SLUG = 'request-access-secrets-manager' @@ -238,6 +248,16 @@ function RouteComponent() { onDelete={handleDeleteSecretManager} onViewAssociatedExternalSecrets={openSecretManagerAssociatedExternalSecretsModal} /> + + + + + + + This feature is in beta. Behaviour and accessibility may change when released in GA. + + +
diff --git a/libs/domains/clusters/feature/src/lib/cluster-creation-flow/step-addons/step-addons.tsx b/libs/domains/clusters/feature/src/lib/cluster-creation-flow/step-addons/step-addons.tsx index e9e38432799..5cae361d1da 100644 --- a/libs/domains/clusters/feature/src/lib/cluster-creation-flow/step-addons/step-addons.tsx +++ b/libs/domains/clusters/feature/src/lib/cluster-creation-flow/step-addons/step-addons.tsx @@ -5,6 +5,7 @@ import { isSameSecretManagerAccess } from '@qovery/domains/clusters/data-access' import { Badge, Button, + Callout, DropdownMenu, FunnelFlowBody, Heading, @@ -186,6 +187,16 @@ function StepAddonsForm({ onSubmit, organizationId, backTo }: StepAddonsFormProp ) } /> + + + + + + + This feature is in beta. Behaviour and accessibility may change when released in GA. + + +
From c3476523597b63e9bbc87d37c69dbe307593d660 Mon Sep 17 00:00:00 2001 From: Melvin Zottola <37779145+mzottola@users.noreply.github.com> Date: Mon, 6 Jul 2026 10:15:34 +0200 Subject: [PATCH 12/15] feat(qov-1844): Add warning message for gke kms key (#2795) feat(qov-1844) Add warning message for gke kms key --- .../src/lib/gcp-kms-key/gke-kms-key.tsx | 33 ++++++++++++++----- 1 file changed, 24 insertions(+), 9 deletions(-) diff --git a/libs/domains/clusters/feature/src/lib/gcp-kms-key/gke-kms-key.tsx b/libs/domains/clusters/feature/src/lib/gcp-kms-key/gke-kms-key.tsx index 42284948dce..3d90f167b0b 100644 --- a/libs/domains/clusters/feature/src/lib/gcp-kms-key/gke-kms-key.tsx +++ b/libs/domains/clusters/feature/src/lib/gcp-kms-key/gke-kms-key.tsx @@ -1,6 +1,6 @@ import { Controller, useFormContext } from 'react-hook-form' import { type ClusterGeneralData } from '@qovery/shared/interfaces' -import { ExternalLink, Icon, InputText, InputToggle, Tooltip } from '@qovery/shared/ui' +import { Callout, ExternalLink, Icon, InputText, InputToggle, Tooltip } from '@qovery/shared/ui' export interface GkeKmsKeyProps { fromDetail?: boolean @@ -82,14 +82,29 @@ export function GkeKmsKey({ fromDetail }: GkeKmsKeyProps) { }, }} render={({ field, fieldState: { error } }) => ( - + <> + + + + + + + + The KMS key must be in the same region as your GKE cluster
+ Never delete the KMS key associated with your cluster - doing so will permanently + and irrecoverably destroy all encrypted data, including node disks, persistent volumes, and + Kubernetes secrets. +
+
+
+ )} /> )} From 00f5946626d50dad5c26f101570b4dde954a5b22 Mon Sep 17 00:00:00 2001 From: Julien Dan <41013692+jul-dan@users.noreply.github.com> Date: Mon, 6 Jul 2026 14:38:22 +0200 Subject: [PATCH 13/15] feat(onboarding): require a professional email on the personalize step (#2798) * feat(onboarding): require a professional email on the personalize step Reject sign-ups using free/personal/disposable email providers (Gmail, Yahoo, Outlook, etc.) so the onboarding form only accepts business email addresses. Co-Authored-By: Claude Sonnet 5 * fix(onboarding): don't prefill a personal email from the social provider The email field was silently pre-populated with the Auth0 social login email (e.g. a Gmail address), which fails the professional-email validation and left the Continue button disabled with no visible error. Now the field is left empty when the social email is a personal domain, and the label is renamed to "Professional email address" to make the requirement clear upfront. Co-Authored-By: Claude Sonnet 5 --------- Co-authored-by: Claude Sonnet 5 --- .../_authenticated/onboarding/personalize.tsx | 7 +- libs/domains/onboarding/feature/src/index.ts | 1 + .../personal-email-domains.ts | 4774 +++++++++++++++++ .../step-personalize.spec.tsx | 27 + .../lib/step-personalize/step-personalize.tsx | 4 +- 5 files changed, 4810 insertions(+), 3 deletions(-) create mode 100644 libs/domains/onboarding/feature/src/lib/step-personalize/personal-email-domains.ts diff --git a/apps/console/src/routes/_authenticated/onboarding/personalize.tsx b/apps/console/src/routes/_authenticated/onboarding/personalize.tsx index 636f6a28e41..0d1fb198077 100644 --- a/apps/console/src/routes/_authenticated/onboarding/personalize.tsx +++ b/apps/console/src/routes/_authenticated/onboarding/personalize.tsx @@ -4,7 +4,7 @@ import posthog from 'posthog-js' import { TypeOfUseEnum } from 'qovery-typescript-axios' import { useContext } from 'react' import { FormProvider, useForm } from 'react-hook-form' -import { Container, ContextOnboarding, StepPersonalize } from '@qovery/domains/onboarding/feature' +import { Container, ContextOnboarding, StepPersonalize, isPersonalEmail } from '@qovery/domains/onboarding/feature' import { useCreateUserSignUp, useUserSignUp } from '@qovery/domains/users-sign-up/feature' import { useAuth } from '@qovery/shared/auth' import { useDocumentTitle } from '@qovery/shared/util-hooks' @@ -23,6 +23,9 @@ function Personalize() { const { data: userSignUp } = useUserSignUp() const { mutateAsync: createUserSignUp } = useCreateUserSignUp() + const socialEmail = userSignUp?.user_email ? userSignUp.user_email : user?.email + const defaultEmail = socialEmail && !isPersonalEmail(socialEmail) ? socialEmail : '' + const methods = useForm<{ first_name: string last_name: string @@ -35,7 +38,7 @@ function Personalize() { defaultValues: { first_name: userSignUp?.first_name ? userSignUp.first_name : user?.name?.split(' ')[0], last_name: userSignUp?.last_name ? userSignUp.last_name : user?.name?.split(' ')[1], - user_email: userSignUp?.user_email ? userSignUp.user_email : user?.email, + user_email: defaultEmail, company_name: userSignUp?.company_name ?? '', type_of_use: userSignUp?.type_of_use ?? TypeOfUseEnum.WORK, phone: '', diff --git a/libs/domains/onboarding/feature/src/index.ts b/libs/domains/onboarding/feature/src/index.ts index ac30cf6a007..9e47ba614c9 100644 --- a/libs/domains/onboarding/feature/src/index.ts +++ b/libs/domains/onboarding/feature/src/index.ts @@ -2,6 +2,7 @@ export * from './lib/accept-invitation/accept-invitation' export * from './lib/container/container' export * from './lib/hooks/use-invite-member/use-invite-member' export * from './lib/step-personalize/step-personalize' +export * from './lib/step-personalize/personal-email-domains' export * from './lib/step-use-cases/step-use-cases' export * from './lib/onboarding-project/onboarding-project' export * from './lib/onboarding-plans/onboarding-plans' diff --git a/libs/domains/onboarding/feature/src/lib/step-personalize/personal-email-domains.ts b/libs/domains/onboarding/feature/src/lib/step-personalize/personal-email-domains.ts new file mode 100644 index 00000000000..f8a6f8cb544 --- /dev/null +++ b/libs/domains/onboarding/feature/src/lib/step-personalize/personal-email-domains.ts @@ -0,0 +1,4774 @@ +export function isPersonalEmail(email: string): boolean { + return PERSONAL_EMAIL_DOMAINS.has(email.split('@')[1]?.toLowerCase()) +} + +export const PERSONAL_EMAIL_DOMAINS = new Set([ + '0-mail.com', + '027168.com', + '0815.su', + '0sg.net', + '10mail.org', + '10minutemail.co.za', + '11mail.com', + '123.com', + '123box.net', + '123india.com', + '123mail.cl', + '123mail.org', + '123qwe.co.uk', + '126.com', + '139.com', + '150mail.com', + '150ml.com', + '15meg4free.com', + '163.com', + '16mail.com', + '188.com', + '189.cn', + '1ce.us', + '1chuan.com', + '1coolplace.com', + '1freeemail.com', + '1funplace.com', + '1internetdrive.com', + '1mail.ml', + '1mail.net', + '1me.net', + '1mum.com', + '1musicrow.com', + '1netdrive.com', + '1nsyncfan.com', + '1pad.de', + '1under.com', + '1webave.com', + '1webhighway.com', + '1zhuan.com', + '2-mail.com', + '20email.eu', + '20mail.in', + '20mail.it', + '212.com', + '21cn.com', + '24horas.com', + '2911.net', + '2980.com', + '2bmail.co.uk', + '2d2i.com', + '2die4.com', + '2trom.com', + '3000.it', + '30minutesmail.com', + '3126.com', + '321media.com', + '33mail.com', + '37.com', + '3ammagazine.com', + '3dmail.com', + '3email.com', + '3g.ua', + '3mail.ga', + '3xl.net', + '444.net', + '4email.com', + '4email.net', + '4mg.com', + '4newyork.com', + '4warding.net', + '4warding.org', + '4x4man.com', + '50mail.com', + '60minutemail.com', + '6ip.us', + '6mail.cf', + '6paq.com', + '74.ru', + '74gmail.com', + '7mail.ga', + '7mail.ml', + '88.am', + '8848.net', + '8mail.ga', + '8mail.ml', + '97rock.com', + '99experts.com', + 'a45.in', + 'aaamail.zzn.com', + 'aamail.net', + 'aapt.net.au', + 'aaronkwok.net', + 'abbeyroadlondon.co.uk', + 'abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyzabcdefghijk.com', + 'abcflash.net', + 'abdulnour.com', + 'aberystwyth.com', + 'about.com', + 'abusemail.de', + 'abv.bg', + 'abwesend.de', + 'abyssmail.com', + 'ac20mail.in', + 'academycougars.com', + 'acceso.or.cr', + 'access4less.net', + 'accessgcc.com', + 'accountant.com', + 'acdcfan.com', + 'ace-of-base.com', + 'acmemail.net', + 'acninc.net', + 'activist.com', + 'adam.com.au', + 'add3000.pp.ua', + 'addcom.de', + 'address.com', + 'adelphia.net', + 'adexec.com', + 'adfarrow.com', + 'adios.net', + 'adoption.com', + 'ados.fr', + 'adrenalinefreak.com', + 'advalvas.be', + 'advantimo.com', + 'aeiou.pt', + 'aemail4u.com', + 'aeneasmail.com', + 'afreeinternet.com', + 'africamail.com', + 'africamel.net', + 'ag.us.to', + 'agoodmail.com', + 'ahaa.dk', + 'ahk.jp', + 'aichi.com', + 'aim.com', + 'aircraftmail.com', + 'airforce.net', + 'airforceemail.com', + 'airpost.net', + 'ajacied.com', + 'ajaxapp.net', + 'ak47.hu', + 'aknet.kg', + 'albawaba.com', + 'alex4all.com', + 'alexandria.cc', + 'algeria.com', + 'alhilal.net', + 'alibaba.com', + 'alice.it', + 'alive.cz', + 'aliyun.com', + 'allergist.com', + 'allmail.net', + 'alloymail.com', + 'allracing.com', + 'allsaintsfan.com', + 'alpenjodel.de', + 'alphafrau.de', + 'alskens.dk', + 'altavista.com', + 'altavista.net', + 'altavista.se', + 'alternativagratis.com', + 'alumni.com', + 'alumnidirector.com', + 'alvilag.hu', + 'amail.com', + 'amazonses.com', + 'amele.com', + 'america.hm', + 'ameritech.net', + 'amnetsal.com', + 'amorki.pl', + 'amrer.net', + 'amuro.net', + 'amuromail.com', + 'ananzi.co.za', + 'andylau.net', + 'anfmail.com', + 'angelfire.com', + 'angelic.com', + 'animail.net', + 'animalhouse.com', + 'animalwoman.net', + 'anjungcafe.com', + 'annsmail.com', + 'ano-mail.net', + 'anonmails.de', + 'anonymous.to', + 'anote.com', + 'another.com', + 'anotherdomaincyka.tk', + 'anotherwin95.com', + 'anti-social.com', + 'antisocial.com', + 'antispam24.de', + 'antongijsen.com', + 'antwerpen.com', + 'anymoment.com', + 'anytimenow.com', + 'aol.com', + 'aon.at', + 'apexmail.com', + 'apmail.com', + 'apollo.lv', + 'aport.ru', + 'aport2000.ru', + 'appraiser.net', + 'approvers.net', + 'arabia.com', + 'arabtop.net', + 'archaeologist.com', + 'arcor.de', + 'arcotronics.bg', + 'arcticmail.com', + 'argentina.com', + 'aristotle.org', + 'army.net', + 'armyspy.com', + 'arnet.com.ar', + 'art-en-ligne.pro', + 'artlover.com', + 'artlover.com.au', + 'as-if.com', + 'asdasd.nl', + 'asean-mail.com', + 'asheville.com', + 'asia-links.com', + 'asia-mail.com', + 'asiafind.com', + 'asianavenue.com', + 'asiancityweb.com', + 'asiansonly.net', + 'asianwired.net', + 'asiapoint.net', + 'ass.pp.ua', + 'assala.com', + 'assamesemail.com', + 'astroboymail.com', + 'astrolover.com', + 'astrosfan.com', + 'astrosfan.net', + 'asurfer.com', + 'atheist.com', + 'athenachu.net', + 'atina.cl', + 'atl.lv', + 'atlaswebmail.com', + 'atmc.net', + 'atozasia.com', + 'atrus.ru', + 'att.net', + 'attglobal.net', + 'attymail.com', + 'au.ru', + 'auctioneer.net', + 'ausi.com', + 'aussiemail.com.au', + 'austin.rr.com', + 'australia.edu', + 'australiamail.com', + 'austrosearch.net', + 'autoescuelanerja.com', + 'autograf.pl', + 'autorambler.ru', + 'avh.hu', + 'avia-tonic.fr', + 'awsom.net', + 'axoskate.com', + 'ayna.com', + 'azazazatashkent.tk', + 'azimiweb.com', + 'azmeil.tk', + 'bachelorboy.com', + 'bachelorgal.com', + 'backpackers.com', + 'backstreet-boys.com', + 'backstreetboysclub.com', + 'bagherpour.com', + 'baldmama.de', + 'baldpapa.de', + 'ballyfinance.com', + 'bangkok.com', + 'bangkok2000.com', + 'bannertown.net', + 'baptistmail.com', + 'baptized.com', + 'barcelona.com', + 'bareed.ws', + 'bartender.net', + 'baseballmail.com', + 'basketballmail.com', + 'batuta.net', + 'baudoinconsulting.com', + 'bboy.zzn.com', + 'bcvibes.com', + 'beddly.com', + 'beeebank.com', + 'beenhad.com', + 'beep.ru', + 'beer.com', + 'beethoven.com', + 'belice.com', + 'belizehome.com', + 'bell.net', + 'bellair.net', + 'bellsouth.net', + 'berlin.com', + 'berlin.de', + 'berlinexpo.de', + 'bestmail.us', + 'betriebsdirektor.de', + 'bettergolf.net', + 'bharatmail.com', + 'big1.us', + 'bigassweb.com', + 'bigblue.net.au', + 'bigboab.com', + 'bigfoot.com', + 'bigfoot.de', + 'bigger.com', + 'biggerbadder.com', + 'bigmailbox.com', + 'bigmir.net', + 'bigpond.au', + 'bigpond.com', + 'bigpond.com.au', + 'bigpond.net', + 'bigpond.net.au', + 'bigramp.com', + 'bigstring.com', + 'bikemechanics.com', + 'bikeracer.com', + 'bikeracers.net', + 'bikerider.com', + 'billsfan.com', + 'billsfan.net', + 'bimla.net', + 'bin-wieder-da.de', + 'bio-muesli.info', + 'birdlover.com', + 'birdowner.net', + 'bisons.com', + 'bitmail.com', + 'bitpage.net', + 'bizhosting.com', + 'bk.ru', + 'blackburnmail.com', + 'blackplanet.com', + 'blader.com', + 'bladesmail.net', + 'blazemail.com', + 'bleib-bei-mir.de', + 'blockfilter.com', + 'blogmyway.org', + 'bluebottle.com', + 'bluehyppo.com', + 'bluemail.ch', + 'bluemail.dk', + 'bluesfan.com', + 'bluewin.ch', + 'blueyonder.co.uk', + 'blushmail.com', + 'blutig.me', + 'bmlsports.net', + 'boardermail.com', + 'boatracers.com', + 'bodhi.lawlita.com', + 'bol.com.br', + 'bolando.com', + 'bollywoodz.com', + 'boltonfans.com', + 'bombdiggity.com', + 'bonbon.net', + 'boom.com', + 'bootmail.com', + 'bootybay.de', + 'bornnaked.com', + 'bostonoffice.com', + 'boun.cr', + 'bounce.net', + 'bounces.amazon.com', + 'bouncr.com', + 'box.az', + 'box.ua', + 'boxbg.com', + 'boxemail.com', + 'boxformail.in', + 'boxfrog.com', + 'boximail.com', + 'boyzoneclub.com', + 'bradfordfans.com', + 'brasilia.net', + 'brazilmail.com', + 'brazilmail.com.br', + 'breadtimes.press', + 'breathe.com', + 'brennendesreich.de', + 'bresnan.net', + 'brew-master.com', + 'brew-meister.com', + 'brfree.com.br', + 'briefemail.com', + 'bright.net', + 'britneyclub.com', + 'brittonsign.com', + 'broadcast.net', + 'brokenvalve.com', + 'brusseler.com', + 'bsdmail.com', + 'btcmail.pw', + 'btconnect.co.uk', + 'btconnect.com', + 'btinternet.com', + 'btopenworld.co.uk', + 'buerotiger.de', + 'buffymail.com', + 'bullsfan.com', + 'bullsgame.com', + 'bumerang.ro', + 'bumpymail.com', + 'bund.us', + 'burnthespam.info', + 'burstmail.info', + 'buryfans.com', + 'business-man.com', + 'businessman.net', + 'busta-rhymes.com', + 'buyersusa.com', + 'bvimailbox.com', + 'byom.de', + 'c2.hu', + 'c2i.net', + 'c3.hu', + 'c4.com', + 'c51vsgq.com', + 'cabacabana.com', + 'cable.comcast.com', + 'cableone.net', + 'caere.it', + 'cairomail.com', + 'calendar-server.bounces.google.com', + 'calidifontain.be', + 'californiamail.com', + 'callnetuk.com', + 'callsign.net', + 'caltanet.it', + 'camidge.com', + 'canada-11.com', + 'canada.com', + 'canadianmail.com', + 'canoemail.com', + 'canwetalk.com', + 'caramail.com', + 'care2.com', + 'careerbuildermail.com', + 'carioca.net', + 'cartelera.org', + 'cartestraina.ro', + 'casablancaresort.com', + 'casema.nl', + 'cash4u.com', + 'cashette.com', + 'casino.com', + 'catcha.com', + 'catchamail.com', + 'catholic.org', + 'catlover.com', + 'cd2.com', + 'celineclub.com', + 'celtic.com', + 'center-mail.de', + 'centermail.at', + 'centermail.de', + 'centermail.info', + 'centoper.it', + 'centralpets.com', + 'centrum.cz', + 'centrum.sk', + 'centurytel.net', + 'certifiedmail.com', + 'cfl.rr.com', + 'cgac.es', + 'cghost.s-a-d.de', + 'chacuo.net', + 'chaiyomail.com', + 'chammy.info', + 'chance2mail.com', + 'chandrasekar.net', + 'charmedmail.com', + 'charter.net', + 'chat.ru', + 'chattown.com', + 'chauhanweb.com', + 'cheatmail.de', + 'chechnya.conf.work', + 'check.com', + 'check1check.com', + 'cheerful.com', + 'chef.net', + 'chek.com', + 'chello.nl', + 'chemist.com', + 'chequemail.com', + 'cheyenneweb.com', + 'chez.com', + 'chickmail.com', + 'china.com', + 'china.net.vg', + 'chinamail.com', + 'chirk.com', + 'chocaholic.com.au', + 'chong-mail.com', + 'chong-mail.net', + 'churchusa.com', + 'cia-agent.com', + 'cia.hu', + 'ciaoweb.it', + 'cicciociccio.com', + 'cinci.rr.com', + 'cincinow.net', + 'citiz.net', + 'citlink.net', + 'citromail.hu', + 'city-of-bath.org', + 'city-of-birmingham.com', + 'city-of-brighton.org', + 'city-of-cambridge.com', + 'city-of-coventry.com', + 'city-of-edinburgh.com', + 'city-of-lichfield.com', + 'city-of-lincoln.com', + 'city-of-liverpool.com', + 'city-of-manchester.com', + 'city-of-nottingham.com', + 'city-of-oxford.com', + 'city-of-swansea.com', + 'city-of-westminster.com', + 'city-of-westminster.net', + 'city-of-york.net', + 'cityofcardiff.net', + 'cityoflondon.org', + 'ckaazaza.tk', + 'claramail.com', + 'classicalfan.com', + 'classicmail.co.za', + 'clear.net.nz', + 'clearwire.net', + 'clerk.com', + 'cliffhanger.com', + 'clixser.com', + 'close2you.net', + 'clrmail.com', + 'club4x4.net', + 'clubalfa.com', + 'clubbers.net', + 'clubducati.com', + 'clubhonda.net', + 'clubmember.org', + 'clubnetnoir.com', + 'clubvdo.net', + 'cluemail.com', + 'cmail.net', + 'cmpmail.com', + 'cnnsimail.com', + 'cntv.cn', + 'codec.ro', + 'coder.hu', + 'coid.biz', + 'coldmail.com', + 'collectiblesuperstore.com', + 'collector.org', + 'collegeclub.com', + 'collegemail.com', + 'colleges.com', + 'columbus.rr.com', + 'columbusrr.com', + 'columnist.com', + 'comcast.net', + 'comic.com', + 'communityconnect.com', + 'comporium.net', + 'comprendemail.com', + 'compuserve.com', + 'computer-freak.com', + 'computer4u.com', + 'computermail.net', + 'conexcol.com', + 'conk.com', + 'connect4free.net', + 'connectbox.com', + 'consultant.com', + 'consumerriot.com', + 'contractor.net', + 'contrasto.cu.cc', + 'cookiemonster.com', + 'cool.br', + 'coole-files.de', + 'coolgoose.ca', + 'coolgoose.com', + 'coolkiwi.com', + 'coollist.com', + 'coolmail.com', + 'coolmail.net', + 'coolsend.com', + 'coolsite.net', + 'cooooool.com', + 'cooperation.net', + 'cooperationtogo.net', + 'copacabana.com', + 'copper.net', + 'cornells.com', + 'cornerpub.com', + 'corporatedirtbag.com', + 'correo.terra.com.gt', + 'cortinet.com', + 'cotas.net', + 'counsellor.com', + 'countrylover.com', + 'cox.com', + 'cox.net', + 'coxinet.net', + 'cracker.hu', + 'crapmail.org', + 'crazedanddazed.com', + 'crazymailing.com', + 'crazysexycool.com', + 'cristianemail.com', + 'critterpost.com', + 'croeso.com', + 'crosshairs.com', + 'crosswinds.net', + 'crwmail.com', + 'cry4helponline.com', + 'cs.com', + 'csinibaba.hu', + 'cuemail.com', + 'curio-city.com', + 'curryworld.de', + 'cute-girl.com', + 'cuteandcuddly.com', + 'cutey.com', + 'cww.de', + 'cyber-africa.net', + 'cyber-innovation.club', + 'cyber-matrix.com', + 'cyber-phone.eu', + 'cyber-wizard.com', + 'cyber4all.com', + 'cyberbabies.com', + 'cybercafemaui.com', + 'cyberdude.com', + 'cyberforeplay.net', + 'cybergal.com', + 'cybergrrl.com', + 'cyberinbox.com', + 'cyberleports.com', + 'cybermail.net', + 'cybernet.it', + 'cyberservices.com', + 'cyberspace-asia.com', + 'cybertrains.org', + 'cyclefanz.com', + 'cynetcity.com', + 'dabsol.net', + 'dadacasa.com', + 'daha.com', + 'dailypioneer.com', + 'dallasmail.com', + 'dangerous-minds.com', + 'dansegulvet.com', + 'dasdasdascyka.tk', + 'data54.com', + 'davegracey.com', + 'dawnsonmail.com', + 'dawsonmail.com', + 'dazedandconfused.com', + 'dbzmail.com', + 'dcemail.com', + 'deadlymob.org', + 'deagot.com', + 'deal-maker.com', + 'dearriba.com', + 'death-star.com', + 'deliveryman.com', + 'deneg.net', + 'depechemode.com', + 'deseretmail.com', + 'desertmail.com', + 'desilota.com', + 'deskpilot.com', + 'destin.com', + 'detik.com', + 'deutschland-net.com', + 'devotedcouples.com', + 'dezigner.ru', + 'dfwatson.com', + 'di-ve.com', + 'die-besten-bilder.de', + 'die-genossen.de', + 'die-optimisten.de', + 'die-optimisten.net', + 'diemailbox.de', + 'digibel.be', + 'digital-filestore.de', + 'diplomats.com', + 'directbox.com', + 'dirtracer.com', + 'discard.email', + 'discard.ga', + 'discard.gq', + 'disciples.com', + 'discofan.com', + 'discoverymail.com', + 'disign-concept.eu', + 'disign-revelation.com', + 'disinfo.net', + 'dispomail.eu', + 'disposable.com', + 'dispose.it', + 'dm.w3internet.co.uk', + 'dmailman.com', + 'dnainternet.net', + 'dnsmadeeasy.com', + 'doclist.bounces.google.com', + 'docmail.cz', + 'docs.google.com', + 'doctor.com', + 'dodgit.org', + 'dodo.com.au', + 'dodsi.com', + 'dog.com', + 'dogit.com', + 'doglover.com', + 'dogmail.co.uk', + 'dogsnob.net', + 'doityourself.com', + 'domforfb1.tk', + 'domforfb2.tk', + 'domforfb3.tk', + 'domforfb4.tk', + 'domforfb5.tk', + 'domforfb6.tk', + 'domforfb7.tk', + 'domforfb8.tk', + 'domozmail.com', + 'doneasy.com', + 'donjuan.com', + 'dontgotmail.com', + 'dontmesswithtexas.com', + 'doramail.com', + 'dostmail.com', + 'dotcom.fr', + 'dotmsg.com', + 'dott.it', + 'download-privat.de', + 'dplanet.ch', + 'dr.com', + 'dragoncon.net', + 'dropmail.me', + 'dropzone.com', + 'drotposta.hu', + 'dubaimail.com', + 'dublin.com', + 'dublin.ie', + 'duck.com', + 'dumpmail.com', + 'dumpmail.de', + 'dumpyemail.com', + 'dunlopdriver.com', + 'dunloprider.com', + 'duno.com', + 'duskmail.com', + 'dutchmail.com', + 'dwp.net', + 'dygo.com', + 'dynamitemail.com', + 'dyndns.org', + 'e-apollo.lv', + 'e-mail.com.tr', + 'e-mail.dk', + 'e-mail.ru', + 'e-mail.ua', + 'e-mailanywhere.com', + 'e-mails.ru', + 'e-tapaal.com', + 'earthalliance.com', + 'earthcam.net', + 'earthdome.com', + 'earthling.net', + 'earthlink.net', + 'earthonline.net', + 'eastcoast.co.za', + 'eastmail.com', + 'easy.to', + 'easypost.com', + 'easytrashmail.com', + 'ec.rr.com', + 'ecardmail.com', + 'ecbsolutions.net', + 'echina.com', + 'ecolo-online.fr', + 'ecompare.com', + 'edmail.com', + 'ednatx.com', + 'edtnmail.com', + 'educacao.te.pt', + 'eelmail.com', + 'ehmail.com', + 'einrot.com', + 'einrot.de', + 'eintagsmail.de', + 'eircom.net', + 'elisanet.fi', + 'elitemail.org', + 'elsitio.com', + 'elvis.com', + 'elvisfan.com', + 'email-fake.gq', + 'email-london.co.uk', + 'email.biz', + 'email.cbes.net', + 'email.com', + 'email.cz', + 'email.ee', + 'email.it', + 'email.nu', + 'email.org', + 'email.ro', + 'email.ru', + 'email.si', + 'email.su', + 'email.ua', + 'email2me.net', + 'email4u.info', + 'emailacc.com', + 'emailaccount.com', + 'emailage.ga', + 'emailage.gq', + 'emailasso.net', + 'emailchoice.com', + 'emailcorner.net', + 'emailem.com', + 'emailengine.net', + 'emailengine.org', + 'emailer.hubspot.com', + 'emailforyou.net', + 'emailgo.de', + 'emailgroups.net', + 'emailinfive.com', + 'emailit.com', + 'emailpinoy.com', + 'emailplanet.com', + 'emailplus.org', + 'emailproxsy.com', + 'emails.ga', + 'emails.incisivemedia.com', + 'emails.ru', + 'emailthe.net', + 'emailto.de', + 'emailuser.net', + 'emailx.net', + 'emailz.ga', + 'emailz.gq', + 'ematic.com', + 'embarqmail.com', + 'emeil.in', + 'emeil.ir', + 'emil.com', + 'eml.cc', + 'eml.pp.ua', + 'end-war.com', + 'enel.net', + 'engineer.com', + 'england.com', + 'england.edu', + 'englandmail.com', + 'epage.ru', + 'epatra.com', + 'ephemail.net', + 'epix.net', + 'epost.de', + 'eposta.hu', + 'eqqu.com', + 'eramail.co.za', + 'eresmas.com', + 'eriga.lv', + 'estranet.it', + 'ethos.st', + 'etoast.com', + 'etrademail.com', + 'etranquil.com', + 'etranquil.net', + 'eudoramail.com', + 'europamel.net', + 'europe.com', + 'europemail.com', + 'euroseek.com', + 'eurosport.com', + 'every1.net', + 'everyday.com.kh', + 'everymail.net', + 'everyone.net', + 'everytg.ml', + 'examnotes.net', + 'excite.co.jp', + 'excite.com', + 'excite.it', + 'execs.com', + 'exemail.com.au', + 'exg6.exghost.com', + 'existiert.net', + 'expressasia.com', + 'extenda.net', + 'extended.com', + 'eyepaste.com', + 'eyou.com', + 'ezcybersearch.com', + 'ezmail.egine.com', + 'ezmail.ru', + 'ezrs.com', + 'f-m.fm', + 'f1fans.net', + 'facebook-email.ga', + 'facebook.com', + 'facebookmail.com', + 'facebookmail.gq', + 'fahr-zur-hoelle.org', + 'fake-email.pp.ua', + 'fake-mail.cf', + 'fake-mail.ga', + 'fake-mail.ml', + 'fakemailz.com', + 'falseaddress.com', + 'fan.com', + 'fansonlymail.com', + 'fansworldwide.de', + 'fantasticmail.com', + 'farang.net', + 'farifluset.mailexpire.com', + 'faroweb.com', + 'fast-email.com', + 'fast-mail.fr', + 'fast-mail.org', + 'fastacura.com', + 'fastchevy.com', + 'fastchrysler.com', + 'fastem.com', + 'fastemail.us', + 'fastemailer.com', + 'fastermail.com', + 'fastest.cc', + 'fastimap.com', + 'fastkawasaki.com', + 'fastmail.ca', + 'fastmail.cn', + 'fastmail.co.uk', + 'fastmail.com', + 'fastmail.com.au', + 'fastmail.es', + 'fastmail.fm', + 'fastmail.im', + 'fastmail.in', + 'fastmail.jp', + 'fastmail.mx', + 'fastmail.net', + 'fastmail.nl', + 'fastmail.se', + 'fastmail.to', + 'fastmail.tw', + 'fastmail.us', + 'fastmailbox.net', + 'fastmazda.com', + 'fastmessaging.com', + 'fastmitsubishi.com', + 'fastnissan.com', + 'fastservice.com', + 'fastsubaru.com', + 'fastsuzuki.com', + 'fasttoyota.com', + 'fastyamaha.com', + 'fatcock.net', + 'fatflap.com', + 'fathersrightsne.org', + 'fax.ru', + 'fbi-agent.com', + 'fbi.hu', + 'fdfdsfds.com', + 'fea.st', + 'federalcontractors.com', + 'feinripptraeger.de', + 'felicitymail.com', + 'femenino.com', + 'fetchmail.co.uk', + 'fettabernett.de', + 'feyenoorder.com', + 'ffanet.com', + 'fiberia.com', + 'ficken.de', + 'fightallspam.com', + 'filipinolinks.com', + 'financemail.net', + 'financier.com', + 'findmail.com', + 'finebody.com', + 'fire-brigade.com', + 'fireman.net', + 'fishburne.org', + 'fishfuse.com', + 'fixmail.tk', + 'fizmail.com', + 'flashbox.5july.org', + 'flashmail.com', + 'flashmail.net', + 'fleckens.hu', + 'flipcode.com', + 'fmail.co.uk', + 'fmailbox.com', + 'fmgirl.com', + 'fmguy.com', + 'fnbmail.co.za', + 'fnmail.com', + 'folkfan.com', + 'foodmail.com', + 'footard.com', + 'footballmail.com', + 'foothills.net', + 'for-president.com', + 'force9.co.uk', + 'forfree.at', + 'forgetmail.com', + 'fornow.eu', + 'forpresident.com', + 'fortuncity.com', + 'fortunecity.com', + 'forum.dk', + 'foxmail.com', + 'fr33mail.info', + 'francemel.fr', + 'free-email.ga', + 'free-online.net', + 'free-org.com', + 'free.com.pe', + 'free.fr', + 'freeaccess.nl', + 'freeaccount.com', + 'freeandsingle.com', + 'freedom.usa.com', + 'freedomlover.com', + 'freegates.be', + 'freeghana.com', + 'freelance-france.eu', + 'freeler.nl', + 'freemail.c3.hu', + 'freemail.com.au', + 'freemail.com.pk', + 'freemail.de', + 'freemail.et', + 'freemail.gr', + 'freemail.hu', + 'freemail.it', + 'freemail.lt', + 'freemail.ms', + 'freemail.nl', + 'freemail.org.mk', + 'freemails.ga', + 'freemeil.gq', + 'freenet.de', + 'freenet.kg', + 'freeola.com', + 'freeola.net', + 'freeserve.co.uk', + 'freestart.hu', + 'freesurf.fr', + 'freesurf.nl', + 'freeuk.com', + 'freeuk.net', + 'freeukisp.co.uk', + 'freeweb.org', + 'freewebemail.com', + 'freeyellow.com', + 'freezone.co.uk', + 'fresnomail.com', + 'freudenkinder.de', + 'freundin.ru', + 'friendlymail.co.uk', + 'friends-cafe.com', + 'friendsfan.com', + 'from-africa.com', + 'from-america.com', + 'from-argentina.com', + 'from-asia.com', + 'from-australia.com', + 'from-belgium.com', + 'from-brazil.com', + 'from-canada.com', + 'from-china.net', + 'from-england.com', + 'from-europe.com', + 'from-france.net', + 'from-germany.net', + 'from-holland.com', + 'from-israel.com', + 'from-italy.net', + 'from-japan.net', + 'from-korea.com', + 'from-mexico.com', + 'from-outerspace.com', + 'from-russia.com', + 'from-spain.net', + 'fromalabama.com', + 'fromalaska.com', + 'fromarizona.com', + 'fromarkansas.com', + 'fromcalifornia.com', + 'fromcolorado.com', + 'fromconnecticut.com', + 'fromdelaware.com', + 'fromflorida.net', + 'fromgeorgia.com', + 'fromhawaii.net', + 'fromidaho.com', + 'fromillinois.com', + 'fromindiana.com', + 'fromiowa.com', + 'fromjupiter.com', + 'fromkansas.com', + 'fromkentucky.com', + 'fromlouisiana.com', + 'frommaine.net', + 'frommaryland.com', + 'frommassachusetts.com', + 'frommiami.com', + 'frommichigan.com', + 'fromminnesota.com', + 'frommississippi.com', + 'frommissouri.com', + 'frommontana.com', + 'fromnebraska.com', + 'fromnevada.com', + 'fromnewhampshire.com', + 'fromnewjersey.com', + 'fromnewmexico.com', + 'fromnewyork.net', + 'fromnorthcarolina.com', + 'fromnorthdakota.com', + 'fromohio.com', + 'fromoklahoma.com', + 'fromoregon.net', + 'frompennsylvania.com', + 'fromrhodeisland.com', + 'fromru.com', + 'fromsouthcarolina.com', + 'fromsouthdakota.com', + 'fromtennessee.com', + 'fromtexas.com', + 'fromthestates.com', + 'fromutah.com', + 'fromvermont.com', + 'fromvirginia.com', + 'fromwashington.com', + 'fromwashingtondc.com', + 'fromwestvirginia.com', + 'fromwisconsin.com', + 'fromwyoming.com', + 'front.ru', + 'frontier.com', + 'frontiernet.net', + 'frostbyte.uk.net', + 'fsmail.net', + 'ftc-i.net', + 'ftml.net', + 'fullmail.com', + 'funkfan.com', + 'fuorissimo.com', + 'furnitureprovider.com', + 'fuse.net', + 'fut.es', + 'fux0ringduh.com', + 'fwnb.com', + 'fxsmails.com', + 'galaxy5.com', + 'galaxyhit.com', + 'gamebox.net', + 'gamegeek.com', + 'gamespotmail.com', + 'gamno.config.work', + 'garbage.com', + 'gardener.com', + 'gawab.com', + 'gaybrighton.co.uk', + 'gaza.net', + 'gazeta.pl', + 'gazibooks.com', + 'gci.net', + 'geecities.com', + 'geek.com', + 'geek.hu', + 'geeklife.com', + 'gelitik.in', + 'gencmail.com', + 'general-hospital.com', + 'gentlemansclub.de', + 'geocities.com', + 'geography.net', + 'geologist.com', + 'geopia.com', + 'germanymail.com', + 'get.pp.ua', + 'get1mail.com', + 'getairmail.cf', + 'getairmail.com', + 'getairmail.ga', + 'getairmail.gq', + 'getonemail.net', + 'ghanamail.com', + 'ghostmail.com', + 'ghosttexter.de', + 'giga4u.de', + 'gigileung.org', + 'girl4god.com', + 'givepeaceachance.com', + 'glay.org', + 'glendale.net', + 'globalfree.it', + 'globalpagan.com', + 'globalsite.com.br', + 'gmail.com', + 'gmail.com.br', + 'gmail.ru', + 'gmx.at', + 'gmx.ch', + 'gmx.com', + 'gmx.de', + 'gmx.li', + 'gmx.net', + 'go.com', + 'go.ro', + 'go.ru', + 'go2net.com', + 'gocollege.com', + 'gocubs.com', + 'goemailgo.com', + 'gofree.co.uk', + 'gol.com', + 'goldenmail.ru', + 'goldmail.ru', + 'goldtoolbox.com', + 'golfemail.com', + 'golfilla.info', + 'golfmail.be', + 'gonavy.net', + 'goodnewsmail.com', + 'goodstick.com', + 'googlegroups.com', + 'googlemail.com', + 'goplay.com', + 'gorillaswithdirtyarmpits.com', + 'gorontalo.net', + 'gospelfan.com', + 'gothere.uk.com', + 'gotmail.com', + 'gotmail.org', + 'gotomy.com', + 'gotti.otherinbox.com', + 'gportal.hu', + 'graduate.org', + 'graffiti.net', + 'gramszu.net', + 'grandmamail.com', + 'grandmasmail.com', + 'graphic-designer.com', + 'grapplers.com', + 'gratisweb.com', + 'greenmail.net', + 'groupmail.com', + 'grr.la', + 'grungecafe.com', + 'gtmc.net', + 'gua.net', + 'guessmail.com', + 'guju.net', + 'gustr.com', + 'guy.com', + 'guy2.com', + 'guyanafriends.com', + 'gyorsposta.com', + 'gyorsposta.hu', + 'h-mail.us', + 'hab-verschlafen.de', + 'habmalnefrage.de', + 'hacccc.com', + 'hackermail.com', + 'hackermail.net', + 'hailmail.net', + 'hairdresser.net', + 'hamptonroads.com', + 'handbag.com', + 'handleit.com', + 'hang-ten.com', + 'hanmail.net', + 'happemail.com', + 'happycounsel.com', + 'happypuppy.com', + 'harakirimail.com', + 'hardcorefreak.com', + 'hartbot.de', + 'hawaii.rr.com', + 'hawaiiantel.net', + 'heartthrob.com', + 'heerschap.com', + 'heesun.net', + 'hehe.com', + 'hello.hu', + 'hello.net.au', + 'hello.to', + 'helter-skelter.com', + 'herediano.com', + 'herono1.com', + 'herp.in', + 'herr-der-mails.de', + 'hetnet.nl', + 'hey.to', + 'hhdevel.com', + 'hidzz.com', + 'highmilton.com', + 'highquality.com', + 'highveldmail.co.za', + 'hilarious.com', + 'hiphopfan.com', + 'hispavista.com', + 'hitmail.com', + 'hitthe.net', + 'hkg.net', + 'hkstarphoto.com', + 'hockeymail.com', + 'hollywoodkids.com', + 'home-email.com', + 'home.de', + 'home.nl', + 'home.no.net', + 'home.ro', + 'home.se', + 'homelocator.com', + 'homemail.com', + 'homestead.com', + 'honduras.com', + 'hongkong.com', + 'hookup.net', + 'hoopsmail.com', + 'hopemail.biz', + 'horrormail.com', + 'hot-mail.gq', + 'hot-shot.com', + 'hot.ee', + 'hotbot.com', + 'hotbrev.com', + 'hotfire.net', + 'hotletter.com', + 'hotmail.ca', + 'hotmail.ch', + 'hotmail.co', + 'hotmail.co.il', + 'hotmail.co.jp', + 'hotmail.co.nz', + 'hotmail.co.uk', + 'hotmail.co.za', + 'hotmail.com', + 'hotmail.com.au', + 'hotmail.com.br', + 'hotmail.com.tr', + 'hotmail.de', + 'hotmail.es', + 'hotmail.fi', + 'hotmail.fr', + 'hotmail.it', + 'hotmail.kg', + 'hotmail.kz', + 'hotmail.nl', + 'hotmail.ru', + 'hotmail.se', + 'hotpop.com', + 'hotpop3.com', + 'hotvoice.com', + 'housemail.com', + 'hsuchi.net', + 'hu2.ru', + 'hughes.net', + 'humanoid.net', + 'humn.ws.gy', + 'hunsa.com', + 'hurting.com', + 'hush.com', + 'hushmail.com', + 'hypernautica.com', + 'i-connect.com', + 'i-france.com', + 'i-mail.com.au', + 'i-p.com', + 'i.am', + 'i.ua', + 'i12.com', + 'i2pmail.org', + 'iamawoman.com', + 'iamwaiting.com', + 'iamwasted.com', + 'iamyours.com', + 'icestorm.com', + 'ich-bin-verrueckt-nach-dir.de', + 'ich-will-net.de', + 'icloud.com', + 'icmsconsultants.com', + 'icq.com', + 'icqmail.com', + 'icrazy.com', + 'id-base.com', + 'ididitmyway.com', + 'idigjesus.com', + 'idirect.com', + 'ieatspam.eu', + 'ieatspam.info', + 'ieh-mail.de', + 'iespana.es', + 'ifoward.com', + 'ig.com.br', + 'ignazio.it', + 'ignmail.com', + 'ihateclowns.com', + 'ihateyoualot.info', + 'iheartspam.org', + 'iinet.net.au', + 'ijustdontcare.com', + 'ikbenspamvrij.nl', + 'ilkposta.com', + 'ilovechocolate.com', + 'ilovejesus.com', + 'ilovetocollect.net', + 'ilse.nl', + 'imaginemail.com', + 'imail.ru', + 'imailbox.com', + 'imap-mail.com', + 'imap.cc', + 'imapmail.org', + 'imel.org', + 'imgof.com', + 'imgv.de', + 'immo-gerance.info', + 'imneverwrong.com', + 'imposter.co.uk', + 'imstations.com', + 'imstressed.com', + 'imtoosexy.com', + 'in-box.net', + 'in2jesus.com', + 'iname.com', + 'inbax.tk', + 'inbound.plus', + 'inbox.com', + 'inbox.net', + 'inbox.ru', + 'inbox.si', + 'inboxalias.com', + 'incamail.com', + 'incredimail.com', + 'indeedemail.com', + 'index.ua', + 'indexa.fr', + 'india.com', + 'indiatimes.com', + 'indo-mail.com', + 'indocities.com', + 'indomail.com', + 'indyracers.com', + 'inerted.com', + 'inet.com', + 'inet.net.au', + 'info-media.de', + 'info-radio.ml', + 'info66.com', + 'infohq.com', + 'infomail.es', + 'infomart.or.jp', + 'infospacemail.com', + 'infovia.com.ar', + 'inicia.es', + 'inmail.sk', + 'inmail24.com', + 'inmano.com', + 'inmynetwork.tk', + 'innocent.com', + 'inorbit.com', + 'inoutbox.com', + 'insidebaltimore.net', + 'insight.rr.com', + 'instant-mail.de', + 'instantemailaddress.com', + 'instantmail.fr', + 'instruction.com', + 'instructor.net', + 'insurer.com', + 'interburp.com', + 'interfree.it', + 'interia.pl', + 'interlap.com.ar', + 'intermail.co.il', + 'internet-e-mail.com', + 'internet-mail.org', + 'internet-police.com', + 'internetbiz.com', + 'internetdrive.com', + 'internetegypt.com', + 'internetemails.net', + 'internetmailing.net', + 'internode.on.net', + 'invalid.com', + 'inwind.it', + 'iobox.com', + 'iobox.fi', + 'iol.it', + 'iol.pt', + 'iowaemail.com', + 'ip3.com', + 'ip4.pp.ua', + 'ip6.pp.ua', + 'ipoo.org', + 'iprimus.com.au', + 'iqemail.com', + 'irangate.net', + 'iraqmail.com', + 'ireland.com', + 'irelandmail.com', + 'iremail.de', + 'irj.hu', + 'iroid.com', + 'isellcars.com', + 'iservejesus.com', + 'islamonline.net', + 'isleuthmail.com', + 'ismart.net', + 'isonfire.com', + 'isp9.net', + 'israelmail.com', + 'ist-allein.info', + 'ist-einmalig.de', + 'ist-ganz-allein.de', + 'ist-willig.de', + 'italymail.com', + 'itloox.com', + 'itmom.com', + 'ivebeenframed.com', + 'ivillage.com', + 'iwan-fals.com', + 'iwmail.com', + 'iwon.com', + 'izadpanah.com', + 'jahoopa.com', + 'jakuza.hu', + 'japan.com', + 'jaydemail.com', + 'jazzandjava.com', + 'jazzfan.com', + 'jazzgame.com', + 'je-recycle.info', + 'jerusalemmail.com', + 'jet-renovation.fr', + 'jetable.de', + 'jetable.pp.ua', + 'jetemail.net', + 'jippii.fi', + 'jmail.co.za', + 'job4u.com', + 'jobbikszimpatizans.hu', + 'joelonsoftware.com', + 'joinme.com', + 'jokes.com', + 'jordanmail.com', + 'journalist.com', + 'jourrapide.com', + 'jovem.te.pt', + 'joymail.com', + 'jpopmail.com', + 'jsrsolutions.com', + 'jubiimail.dk', + 'jump.com', + 'jumpy.it', + 'juniormail.com', + 'junk1e.com', + 'junkmail.com', + 'junkmail.gq', + 'juno.com', + 'justemail.net', + 'justicemail.com', + 'kaazoo.com', + 'kaffeeschluerfer.com', + 'kaffeeschluerfer.de', + 'kaixo.com', + 'kalpoint.com', + 'kansascity.com', + 'kapoorweb.com', + 'karachian.com', + 'karachioye.com', + 'karbasi.com', + 'katamail.com', + 'kayafmmail.co.za', + 'kbjrmail.com', + 'kcks.com', + 'keg-party.com', + 'keinpardon.de', + 'keko.com.ar', + 'kellychen.com', + 'keromail.com', + 'keyemail.com', + 'kgb.hu', + 'khosropour.com', + 'kickassmail.com', + 'killermail.com', + 'kimo.com', + 'kimsdisk.com', + 'kinglibrary.net', + 'kinki-kids.com', + 'kissfans.com', + 'kittymail.com', + 'kitznet.at', + 'kiwibox.com', + 'kiwitown.com', + 'klassmaster.net', + 'km.ru', + 'knol-power.nl', + 'kolumbus.fi', + 'kommespaeter.de', + 'konx.com', + 'korea.com', + 'koreamail.com', + 'kpnmail.nl', + 'krim.ws', + 'krongthip.com', + 'krunis.com', + 'ksanmail.com', + 'ksee24mail.com', + 'kube93mail.com', + 'kukamail.com', + 'kulturbetrieb.info', + 'kumarweb.com', + 'kuwait-mail.com', + 'l33r.eu', + 'la.com', + 'labetteraverouge.at', + 'ladymail.cz', + 'lagerlouts.com', + 'lags.us', + 'lahoreoye.com', + 'lakmail.com', + 'lamer.hu', + 'land.ru', + 'lankamail.com', + 'laoeq.com', + 'laposte.net', + 'lass-es-geschehen.de', + 'last-chance.pro', + 'lastmail.co', + 'latemodels.com', + 'latinmail.com', + 'lavache.com', + 'law.com', + 'lawyer.com', + 'lazyinbox.com', + 'leehom.net', + 'legalactions.com', + 'legalrc.loan', + 'legislator.com', + 'lenta.ru', + 'leonlai.net', + 'letsgomets.net', + 'letterboxes.org', + 'letthemeatspam.com', + 'levele.com', + 'levele.hu', + 'lex.bg', + 'lexis-nexis-mail.com', + 'libero.it', + 'liberomail.com', + 'lick101.com', + 'liebt-dich.info', + 'linkmaster.com', + 'linktrader.com', + 'linuxfreemail.com', + 'linuxmail.org', + 'lionsfan.com.au', + 'liontrucks.com', + 'liquidinformation.net', + 'list.ru', + 'listomail.com', + 'littleapple.com', + 'littleblueroom.com', + 'live.at', + 'live.be', + 'live.ca', + 'live.cl', + 'live.cn', + 'live.co.uk', + 'live.co.za', + 'live.com', + 'live.com.ar', + 'live.com.au', + 'live.com.mx', + 'live.com.pt', + 'live.com.sg', + 'live.de', + 'live.dk', + 'live.fr', + 'live.ie', + 'live.in', + 'live.it', + 'live.jp', + 'live.nl', + 'live.no', + 'live.ru', + 'live.se', + 'liveradio.tk', + 'liverpoolfans.com', + 'llandudno.com', + 'llangollen.com', + 'lmxmail.sk', + 'lobbyist.com', + 'localbar.com', + 'locos.com', + 'login-email.ga', + 'loh.pp.ua', + 'lolfreak.net', + 'lolito.tk', + 'london.com', + 'looksmart.co.uk', + 'looksmart.com', + 'looksmart.com.au', + 'lopezclub.com', + 'louiskoo.com', + 'love.cz', + 'loveable.com', + 'lovecat.com', + 'lovefall.ml', + 'lovefootball.com', + 'lovelygirl.net', + 'lovemail.com', + 'lover-boy.com', + 'lovergirl.com', + 'lovesea.gq', + 'lovethebroncos.com', + 'lovethecowboys.com', + 'loveyouforever.de', + 'lovingjesus.com', + 'lowandslow.com', + 'lr7.us', + 'lroid.com', + 'luso.pt', + 'luukku.com', + 'luv2.us', + 'lvie.com.sg', + 'lycos.co.uk', + 'lycos.com', + 'lycos.es', + 'lycos.it', + 'lycos.ne.jp', + 'lycosmail.com', + 'm-a-i-l.com', + 'm-hmail.com', + 'm4.org', + 'm4ilweb.info', + 'mac.com', + 'macbox.com', + 'macfreak.com', + 'machinecandy.com', + 'macmail.com', + 'madcreations.com', + 'madonnafan.com', + 'madrid.com', + 'maennerversteherin.com', + 'maennerversteherin.de', + 'maffia.hu', + 'magicmail.co.za', + 'magspam.net', + 'mahmoodweb.com', + 'mail-awu.de', + 'mail-box.cz', + 'mail-center.com', + 'mail-central.com', + 'mail-easy.fr', + 'mail-filter.com', + 'mail-me.com', + 'mail-page.com', + 'mail-tester.com', + 'mail.austria.com', + 'mail.az', + 'mail.be', + 'mail.bg', + 'mail.bulgaria.com', + 'mail.by', + 'mail.co.za', + 'mail.com', + 'mail.com.tr', + 'mail.de', + 'mail.ee', + 'mail.entrepeneurmag.com', + 'mail.freetown.com', + 'mail.gr', + 'mail.hitthebeach.com', + 'mail.htl22.at', + 'mail.md', + 'mail.misterpinball.de', + 'mail.nu', + 'mail.org.uk', + 'mail.pf', + 'mail.pt', + 'mail.r-o-o-t.com', + 'mail.ru', + 'mail.sisna.com', + 'mail.svenz.eu', + 'mail.usa.com', + 'mail.vasarhely.hu', + 'mail.wtf', + 'mail114.net', + 'mail15.com', + 'mail2007.com', + 'mail2aaron.com', + 'mail2abby.com', + 'mail2abc.com', + 'mail2actor.com', + 'mail2admiral.com', + 'mail2adorable.com', + 'mail2adoration.com', + 'mail2adore.com', + 'mail2adventure.com', + 'mail2aeolus.com', + 'mail2aether.com', + 'mail2affection.com', + 'mail2afghanistan.com', + 'mail2africa.com', + 'mail2agent.com', + 'mail2aha.com', + 'mail2ahoy.com', + 'mail2aim.com', + 'mail2air.com', + 'mail2airbag.com', + 'mail2airforce.com', + 'mail2airport.com', + 'mail2alabama.com', + 'mail2alan.com', + 'mail2alaska.com', + 'mail2albania.com', + 'mail2alcoholic.com', + 'mail2alec.com', + 'mail2alexa.com', + 'mail2algeria.com', + 'mail2alicia.com', + 'mail2alien.com', + 'mail2allan.com', + 'mail2allen.com', + 'mail2allison.com', + 'mail2alpha.com', + 'mail2alyssa.com', + 'mail2amanda.com', + 'mail2amazing.com', + 'mail2amber.com', + 'mail2america.com', + 'mail2american.com', + 'mail2andorra.com', + 'mail2andrea.com', + 'mail2andy.com', + 'mail2anesthesiologist.com', + 'mail2angela.com', + 'mail2angola.com', + 'mail2ann.com', + 'mail2anna.com', + 'mail2anne.com', + 'mail2anthony.com', + 'mail2anything.com', + 'mail2aphrodite.com', + 'mail2apollo.com', + 'mail2april.com', + 'mail2aquarius.com', + 'mail2arabia.com', + 'mail2arabic.com', + 'mail2architect.com', + 'mail2ares.com', + 'mail2argentina.com', + 'mail2aries.com', + 'mail2arizona.com', + 'mail2arkansas.com', + 'mail2armenia.com', + 'mail2army.com', + 'mail2arnold.com', + 'mail2art.com', + 'mail2artemus.com', + 'mail2arthur.com', + 'mail2artist.com', + 'mail2ashley.com', + 'mail2ask.com', + 'mail2astronomer.com', + 'mail2athena.com', + 'mail2athlete.com', + 'mail2atlas.com', + 'mail2atom.com', + 'mail2attitude.com', + 'mail2auction.com', + 'mail2aunt.com', + 'mail2australia.com', + 'mail2austria.com', + 'mail2azerbaijan.com', + 'mail2baby.com', + 'mail2bahamas.com', + 'mail2bahrain.com', + 'mail2ballerina.com', + 'mail2ballplayer.com', + 'mail2band.com', + 'mail2bangladesh.com', + 'mail2bank.com', + 'mail2banker.com', + 'mail2bankrupt.com', + 'mail2baptist.com', + 'mail2bar.com', + 'mail2barbados.com', + 'mail2barbara.com', + 'mail2barter.com', + 'mail2basketball.com', + 'mail2batter.com', + 'mail2beach.com', + 'mail2beast.com', + 'mail2beatles.com', + 'mail2beauty.com', + 'mail2becky.com', + 'mail2beijing.com', + 'mail2belgium.com', + 'mail2belize.com', + 'mail2ben.com', + 'mail2bernard.com', + 'mail2beth.com', + 'mail2betty.com', + 'mail2beverly.com', + 'mail2beyond.com', + 'mail2biker.com', + 'mail2bill.com', + 'mail2billionaire.com', + 'mail2billy.com', + 'mail2bio.com', + 'mail2biologist.com', + 'mail2black.com', + 'mail2blackbelt.com', + 'mail2blake.com', + 'mail2blind.com', + 'mail2blonde.com', + 'mail2blues.com', + 'mail2bob.com', + 'mail2bobby.com', + 'mail2bolivia.com', + 'mail2bombay.com', + 'mail2bonn.com', + 'mail2bookmark.com', + 'mail2boreas.com', + 'mail2bosnia.com', + 'mail2boston.com', + 'mail2botswana.com', + 'mail2bradley.com', + 'mail2brazil.com', + 'mail2breakfast.com', + 'mail2brian.com', + 'mail2bride.com', + 'mail2brittany.com', + 'mail2broker.com', + 'mail2brook.com', + 'mail2bruce.com', + 'mail2brunei.com', + 'mail2brunette.com', + 'mail2brussels.com', + 'mail2bryan.com', + 'mail2bug.com', + 'mail2bulgaria.com', + 'mail2business.com', + 'mail2buy.com', + 'mail2ca.com', + 'mail2california.com', + 'mail2calvin.com', + 'mail2cambodia.com', + 'mail2cameroon.com', + 'mail2canada.com', + 'mail2cancer.com', + 'mail2capeverde.com', + 'mail2capricorn.com', + 'mail2cardinal.com', + 'mail2cardiologist.com', + 'mail2care.com', + 'mail2caroline.com', + 'mail2carolyn.com', + 'mail2casey.com', + 'mail2cat.com', + 'mail2caterer.com', + 'mail2cathy.com', + 'mail2catlover.com', + 'mail2catwalk.com', + 'mail2cell.com', + 'mail2chad.com', + 'mail2champaign.com', + 'mail2charles.com', + 'mail2chef.com', + 'mail2chemist.com', + 'mail2cherry.com', + 'mail2chicago.com', + 'mail2chile.com', + 'mail2china.com', + 'mail2chinese.com', + 'mail2chocolate.com', + 'mail2christian.com', + 'mail2christie.com', + 'mail2christmas.com', + 'mail2christy.com', + 'mail2chuck.com', + 'mail2cindy.com', + 'mail2clark.com', + 'mail2classifieds.com', + 'mail2claude.com', + 'mail2cliff.com', + 'mail2clinic.com', + 'mail2clint.com', + 'mail2close.com', + 'mail2club.com', + 'mail2coach.com', + 'mail2coastguard.com', + 'mail2colin.com', + 'mail2college.com', + 'mail2colombia.com', + 'mail2color.com', + 'mail2colorado.com', + 'mail2columbia.com', + 'mail2comedian.com', + 'mail2composer.com', + 'mail2computer.com', + 'mail2computers.com', + 'mail2concert.com', + 'mail2congo.com', + 'mail2connect.com', + 'mail2connecticut.com', + 'mail2consultant.com', + 'mail2convict.com', + 'mail2cook.com', + 'mail2cool.com', + 'mail2cory.com', + 'mail2costarica.com', + 'mail2country.com', + 'mail2courtney.com', + 'mail2cowboy.com', + 'mail2cowgirl.com', + 'mail2craig.com', + 'mail2crave.com', + 'mail2crazy.com', + 'mail2create.com', + 'mail2croatia.com', + 'mail2cry.com', + 'mail2crystal.com', + 'mail2cuba.com', + 'mail2culture.com', + 'mail2curt.com', + 'mail2customs.com', + 'mail2cute.com', + 'mail2cutey.com', + 'mail2cynthia.com', + 'mail2cyprus.com', + 'mail2czechrepublic.com', + 'mail2dad.com', + 'mail2dale.com', + 'mail2dallas.com', + 'mail2dan.com', + 'mail2dana.com', + 'mail2dance.com', + 'mail2dancer.com', + 'mail2danielle.com', + 'mail2danny.com', + 'mail2darlene.com', + 'mail2darling.com', + 'mail2darren.com', + 'mail2daughter.com', + 'mail2dave.com', + 'mail2dawn.com', + 'mail2dc.com', + 'mail2dealer.com', + 'mail2deanna.com', + 'mail2dearest.com', + 'mail2debbie.com', + 'mail2debby.com', + 'mail2deer.com', + 'mail2delaware.com', + 'mail2delicious.com', + 'mail2demeter.com', + 'mail2democrat.com', + 'mail2denise.com', + 'mail2denmark.com', + 'mail2dennis.com', + 'mail2dentist.com', + 'mail2derek.com', + 'mail2desert.com', + 'mail2devoted.com', + 'mail2devotion.com', + 'mail2diamond.com', + 'mail2diana.com', + 'mail2diane.com', + 'mail2diehard.com', + 'mail2dilemma.com', + 'mail2dillon.com', + 'mail2dinner.com', + 'mail2dinosaur.com', + 'mail2dionysos.com', + 'mail2diplomat.com', + 'mail2director.com', + 'mail2dirk.com', + 'mail2disco.com', + 'mail2dive.com', + 'mail2diver.com', + 'mail2divorced.com', + 'mail2djibouti.com', + 'mail2doctor.com', + 'mail2doglover.com', + 'mail2dominic.com', + 'mail2dominica.com', + 'mail2dominicanrepublic.com', + 'mail2don.com', + 'mail2donald.com', + 'mail2donna.com', + 'mail2doris.com', + 'mail2dorothy.com', + 'mail2doug.com', + 'mail2dough.com', + 'mail2douglas.com', + 'mail2dow.com', + 'mail2downtown.com', + 'mail2dream.com', + 'mail2dreamer.com', + 'mail2dude.com', + 'mail2dustin.com', + 'mail2dyke.com', + 'mail2dylan.com', + 'mail2earl.com', + 'mail2earth.com', + 'mail2eastend.com', + 'mail2eat.com', + 'mail2economist.com', + 'mail2ecuador.com', + 'mail2eddie.com', + 'mail2edgar.com', + 'mail2edwin.com', + 'mail2egypt.com', + 'mail2electron.com', + 'mail2eli.com', + 'mail2elizabeth.com', + 'mail2ellen.com', + 'mail2elliot.com', + 'mail2elsalvador.com', + 'mail2elvis.com', + 'mail2emergency.com', + 'mail2emily.com', + 'mail2engineer.com', + 'mail2english.com', + 'mail2environmentalist.com', + 'mail2eos.com', + 'mail2eric.com', + 'mail2erica.com', + 'mail2erin.com', + 'mail2erinyes.com', + 'mail2eris.com', + 'mail2eritrea.com', + 'mail2ernie.com', + 'mail2eros.com', + 'mail2estonia.com', + 'mail2ethan.com', + 'mail2ethiopia.com', + 'mail2eu.com', + 'mail2europe.com', + 'mail2eurus.com', + 'mail2eva.com', + 'mail2evan.com', + 'mail2evelyn.com', + 'mail2everything.com', + 'mail2exciting.com', + 'mail2expert.com', + 'mail2fairy.com', + 'mail2faith.com', + 'mail2fanatic.com', + 'mail2fancy.com', + 'mail2fantasy.com', + 'mail2farm.com', + 'mail2farmer.com', + 'mail2fashion.com', + 'mail2fat.com', + 'mail2feeling.com', + 'mail2female.com', + 'mail2fever.com', + 'mail2fighter.com', + 'mail2fiji.com', + 'mail2filmfestival.com', + 'mail2films.com', + 'mail2finance.com', + 'mail2finland.com', + 'mail2fireman.com', + 'mail2firm.com', + 'mail2fisherman.com', + 'mail2flexible.com', + 'mail2florence.com', + 'mail2florida.com', + 'mail2floyd.com', + 'mail2fly.com', + 'mail2fond.com', + 'mail2fondness.com', + 'mail2football.com', + 'mail2footballfan.com', + 'mail2found.com', + 'mail2france.com', + 'mail2frank.com', + 'mail2frankfurt.com', + 'mail2franklin.com', + 'mail2fred.com', + 'mail2freddie.com', + 'mail2free.com', + 'mail2freedom.com', + 'mail2french.com', + 'mail2freudian.com', + 'mail2friendship.com', + 'mail2from.com', + 'mail2fun.com', + 'mail2gabon.com', + 'mail2gabriel.com', + 'mail2gail.com', + 'mail2galaxy.com', + 'mail2gambia.com', + 'mail2games.com', + 'mail2gary.com', + 'mail2gavin.com', + 'mail2gemini.com', + 'mail2gene.com', + 'mail2genes.com', + 'mail2geneva.com', + 'mail2george.com', + 'mail2georgia.com', + 'mail2gerald.com', + 'mail2german.com', + 'mail2germany.com', + 'mail2ghana.com', + 'mail2gilbert.com', + 'mail2gina.com', + 'mail2girl.com', + 'mail2glen.com', + 'mail2gloria.com', + 'mail2goddess.com', + 'mail2gold.com', + 'mail2golfclub.com', + 'mail2golfer.com', + 'mail2gordon.com', + 'mail2government.com', + 'mail2grab.com', + 'mail2grace.com', + 'mail2graham.com', + 'mail2grandma.com', + 'mail2grandpa.com', + 'mail2grant.com', + 'mail2greece.com', + 'mail2green.com', + 'mail2greg.com', + 'mail2grenada.com', + 'mail2gsm.com', + 'mail2guard.com', + 'mail2guatemala.com', + 'mail2guy.com', + 'mail2hades.com', + 'mail2haiti.com', + 'mail2hal.com', + 'mail2handhelds.com', + 'mail2hank.com', + 'mail2hannah.com', + 'mail2harold.com', + 'mail2harry.com', + 'mail2hawaii.com', + 'mail2headhunter.com', + 'mail2heal.com', + 'mail2heather.com', + 'mail2heaven.com', + 'mail2hebe.com', + 'mail2hecate.com', + 'mail2heidi.com', + 'mail2helen.com', + 'mail2hell.com', + 'mail2help.com', + 'mail2helpdesk.com', + 'mail2henry.com', + 'mail2hephaestus.com', + 'mail2hera.com', + 'mail2hercules.com', + 'mail2herman.com', + 'mail2hermes.com', + 'mail2hespera.com', + 'mail2hestia.com', + 'mail2highschool.com', + 'mail2hindu.com', + 'mail2hip.com', + 'mail2hiphop.com', + 'mail2holland.com', + 'mail2holly.com', + 'mail2hollywood.com', + 'mail2homer.com', + 'mail2honduras.com', + 'mail2honey.com', + 'mail2hongkong.com', + 'mail2hope.com', + 'mail2horse.com', + 'mail2hot.com', + 'mail2hotel.com', + 'mail2houston.com', + 'mail2howard.com', + 'mail2hugh.com', + 'mail2human.com', + 'mail2hungary.com', + 'mail2hungry.com', + 'mail2hygeia.com', + 'mail2hyperspace.com', + 'mail2hypnos.com', + 'mail2ian.com', + 'mail2ice-cream.com', + 'mail2iceland.com', + 'mail2idaho.com', + 'mail2idontknow.com', + 'mail2illinois.com', + 'mail2imam.com', + 'mail2in.com', + 'mail2india.com', + 'mail2indian.com', + 'mail2indiana.com', + 'mail2indonesia.com', + 'mail2infinity.com', + 'mail2intense.com', + 'mail2iowa.com', + 'mail2iran.com', + 'mail2iraq.com', + 'mail2ireland.com', + 'mail2irene.com', + 'mail2iris.com', + 'mail2irresistible.com', + 'mail2irving.com', + 'mail2irwin.com', + 'mail2isaac.com', + 'mail2israel.com', + 'mail2italian.com', + 'mail2italy.com', + 'mail2jackie.com', + 'mail2jacob.com', + 'mail2jail.com', + 'mail2jaime.com', + 'mail2jake.com', + 'mail2jamaica.com', + 'mail2james.com', + 'mail2jamie.com', + 'mail2jan.com', + 'mail2jane.com', + 'mail2janet.com', + 'mail2janice.com', + 'mail2japan.com', + 'mail2japanese.com', + 'mail2jasmine.com', + 'mail2jason.com', + 'mail2java.com', + 'mail2jay.com', + 'mail2jazz.com', + 'mail2jed.com', + 'mail2jeffrey.com', + 'mail2jennifer.com', + 'mail2jenny.com', + 'mail2jeremy.com', + 'mail2jerry.com', + 'mail2jessica.com', + 'mail2jessie.com', + 'mail2jesus.com', + 'mail2jew.com', + 'mail2jeweler.com', + 'mail2jim.com', + 'mail2jimmy.com', + 'mail2joan.com', + 'mail2joann.com', + 'mail2joanna.com', + 'mail2jody.com', + 'mail2joe.com', + 'mail2joel.com', + 'mail2joey.com', + 'mail2john.com', + 'mail2join.com', + 'mail2jon.com', + 'mail2jonathan.com', + 'mail2jones.com', + 'mail2jordan.com', + 'mail2joseph.com', + 'mail2josh.com', + 'mail2joy.com', + 'mail2juan.com', + 'mail2judge.com', + 'mail2judy.com', + 'mail2juggler.com', + 'mail2julian.com', + 'mail2julie.com', + 'mail2jumbo.com', + 'mail2junk.com', + 'mail2justin.com', + 'mail2justme.com', + 'mail2k.ru', + 'mail2kansas.com', + 'mail2karate.com', + 'mail2karen.com', + 'mail2karl.com', + 'mail2karma.com', + 'mail2kathleen.com', + 'mail2kathy.com', + 'mail2katie.com', + 'mail2kay.com', + 'mail2kazakhstan.com', + 'mail2keen.com', + 'mail2keith.com', + 'mail2kelly.com', + 'mail2kelsey.com', + 'mail2ken.com', + 'mail2kendall.com', + 'mail2kennedy.com', + 'mail2kenneth.com', + 'mail2kenny.com', + 'mail2kentucky.com', + 'mail2kenya.com', + 'mail2kerry.com', + 'mail2kevin.com', + 'mail2kim.com', + 'mail2kimberly.com', + 'mail2king.com', + 'mail2kirk.com', + 'mail2kiss.com', + 'mail2kosher.com', + 'mail2kristin.com', + 'mail2kurt.com', + 'mail2kuwait.com', + 'mail2kyle.com', + 'mail2kyrgyzstan.com', + 'mail2la.com', + 'mail2lacrosse.com', + 'mail2lance.com', + 'mail2lao.com', + 'mail2larry.com', + 'mail2latvia.com', + 'mail2laugh.com', + 'mail2laura.com', + 'mail2lauren.com', + 'mail2laurie.com', + 'mail2lawrence.com', + 'mail2lawyer.com', + 'mail2lebanon.com', + 'mail2lee.com', + 'mail2leo.com', + 'mail2leon.com', + 'mail2leonard.com', + 'mail2leone.com', + 'mail2leslie.com', + 'mail2letter.com', + 'mail2liberia.com', + 'mail2libertarian.com', + 'mail2libra.com', + 'mail2libya.com', + 'mail2liechtenstein.com', + 'mail2life.com', + 'mail2linda.com', + 'mail2linux.com', + 'mail2lionel.com', + 'mail2lipstick.com', + 'mail2liquid.com', + 'mail2lisa.com', + 'mail2lithuania.com', + 'mail2litigator.com', + 'mail2liz.com', + 'mail2lloyd.com', + 'mail2lois.com', + 'mail2lola.com', + 'mail2london.com', + 'mail2looking.com', + 'mail2lori.com', + 'mail2lost.com', + 'mail2lou.com', + 'mail2louis.com', + 'mail2louisiana.com', + 'mail2lovable.com', + 'mail2love.com', + 'mail2lucky.com', + 'mail2lucy.com', + 'mail2lunch.com', + 'mail2lust.com', + 'mail2luxembourg.com', + 'mail2luxury.com', + 'mail2lyle.com', + 'mail2lynn.com', + 'mail2madagascar.com', + 'mail2madison.com', + 'mail2madrid.com', + 'mail2maggie.com', + 'mail2mail4.com', + 'mail2maine.com', + 'mail2malawi.com', + 'mail2malaysia.com', + 'mail2maldives.com', + 'mail2mali.com', + 'mail2malta.com', + 'mail2mambo.com', + 'mail2man.com', + 'mail2mandy.com', + 'mail2manhunter.com', + 'mail2mankind.com', + 'mail2many.com', + 'mail2marc.com', + 'mail2marcia.com', + 'mail2margaret.com', + 'mail2margie.com', + 'mail2marhaba.com', + 'mail2maria.com', + 'mail2marilyn.com', + 'mail2marines.com', + 'mail2mark.com', + 'mail2marriage.com', + 'mail2married.com', + 'mail2marries.com', + 'mail2mars.com', + 'mail2marsha.com', + 'mail2marshallislands.com', + 'mail2martha.com', + 'mail2martin.com', + 'mail2marty.com', + 'mail2marvin.com', + 'mail2mary.com', + 'mail2maryland.com', + 'mail2mason.com', + 'mail2massachusetts.com', + 'mail2matt.com', + 'mail2matthew.com', + 'mail2maurice.com', + 'mail2mauritania.com', + 'mail2mauritius.com', + 'mail2max.com', + 'mail2maxwell.com', + 'mail2maybe.com', + 'mail2mba.com', + 'mail2me4u.com', + 'mail2mechanic.com', + 'mail2medieval.com', + 'mail2megan.com', + 'mail2mel.com', + 'mail2melanie.com', + 'mail2melissa.com', + 'mail2melody.com', + 'mail2member.com', + 'mail2memphis.com', + 'mail2methodist.com', + 'mail2mexican.com', + 'mail2mexico.com', + 'mail2mgz.com', + 'mail2miami.com', + 'mail2michael.com', + 'mail2michelle.com', + 'mail2michigan.com', + 'mail2mike.com', + 'mail2milan.com', + 'mail2milano.com', + 'mail2mildred.com', + 'mail2milkyway.com', + 'mail2millennium.com', + 'mail2millionaire.com', + 'mail2milton.com', + 'mail2mime.com', + 'mail2mindreader.com', + 'mail2mini.com', + 'mail2minister.com', + 'mail2minneapolis.com', + 'mail2minnesota.com', + 'mail2miracle.com', + 'mail2missionary.com', + 'mail2mississippi.com', + 'mail2missouri.com', + 'mail2mitch.com', + 'mail2model.com', + 'mail2moldova.commail2molly.com', + 'mail2mom.com', + 'mail2monaco.com', + 'mail2money.com', + 'mail2mongolia.com', + 'mail2monica.com', + 'mail2montana.com', + 'mail2monty.com', + 'mail2moon.com', + 'mail2morocco.com', + 'mail2morpheus.com', + 'mail2mors.com', + 'mail2moscow.com', + 'mail2moslem.com', + 'mail2mouseketeer.com', + 'mail2movies.com', + 'mail2mozambique.com', + 'mail2mp3.com', + 'mail2mrright.com', + 'mail2msright.com', + 'mail2museum.com', + 'mail2music.com', + 'mail2musician.com', + 'mail2muslim.com', + 'mail2my.com', + 'mail2myboat.com', + 'mail2mycar.com', + 'mail2mycell.com', + 'mail2mygsm.com', + 'mail2mylaptop.com', + 'mail2mymac.com', + 'mail2mypager.com', + 'mail2mypalm.com', + 'mail2mypc.com', + 'mail2myphone.com', + 'mail2myplane.com', + 'mail2namibia.com', + 'mail2nancy.com', + 'mail2nasdaq.com', + 'mail2nathan.com', + 'mail2nauru.com', + 'mail2navy.com', + 'mail2neal.com', + 'mail2nebraska.com', + 'mail2ned.com', + 'mail2neil.com', + 'mail2nelson.com', + 'mail2nemesis.com', + 'mail2nepal.com', + 'mail2netherlands.com', + 'mail2network.com', + 'mail2nevada.com', + 'mail2newhampshire.com', + 'mail2newjersey.com', + 'mail2newmexico.com', + 'mail2newyork.com', + 'mail2newzealand.com', + 'mail2nicaragua.com', + 'mail2nick.com', + 'mail2nicole.com', + 'mail2niger.com', + 'mail2nigeria.com', + 'mail2nike.com', + 'mail2no.com', + 'mail2noah.com', + 'mail2noel.com', + 'mail2noelle.com', + 'mail2normal.com', + 'mail2norman.com', + 'mail2northamerica.com', + 'mail2northcarolina.com', + 'mail2northdakota.com', + 'mail2northpole.com', + 'mail2norway.com', + 'mail2notus.com', + 'mail2noway.com', + 'mail2nowhere.com', + 'mail2nuclear.com', + 'mail2nun.com', + 'mail2ny.com', + 'mail2oasis.com', + 'mail2oceanographer.com', + 'mail2ohio.com', + 'mail2ok.com', + 'mail2oklahoma.com', + 'mail2oliver.com', + 'mail2oman.com', + 'mail2one.com', + 'mail2onfire.com', + 'mail2online.com', + 'mail2oops.com', + 'mail2open.com', + 'mail2ophthalmologist.com', + 'mail2optometrist.com', + 'mail2oregon.com', + 'mail2oscars.com', + 'mail2oslo.com', + 'mail2painter.com', + 'mail2pakistan.com', + 'mail2palau.com', + 'mail2pan.com', + 'mail2panama.com', + 'mail2paraguay.com', + 'mail2paralegal.com', + 'mail2paris.com', + 'mail2park.com', + 'mail2parker.com', + 'mail2party.com', + 'mail2passion.com', + 'mail2pat.com', + 'mail2patricia.com', + 'mail2patrick.com', + 'mail2patty.com', + 'mail2paul.com', + 'mail2paula.com', + 'mail2pay.com', + 'mail2peace.com', + 'mail2pediatrician.com', + 'mail2peggy.com', + 'mail2pennsylvania.com', + 'mail2perry.com', + 'mail2persephone.com', + 'mail2persian.com', + 'mail2peru.com', + 'mail2pete.com', + 'mail2peter.com', + 'mail2pharmacist.com', + 'mail2phil.com', + 'mail2philippines.com', + 'mail2phoenix.com', + 'mail2phonecall.com', + 'mail2phyllis.com', + 'mail2pickup.com', + 'mail2pilot.com', + 'mail2pisces.com', + 'mail2planet.com', + 'mail2platinum.com', + 'mail2plato.com', + 'mail2pluto.com', + 'mail2pm.com', + 'mail2podiatrist.com', + 'mail2poet.com', + 'mail2poland.com', + 'mail2policeman.com', + 'mail2policewoman.com', + 'mail2politician.com', + 'mail2pop.com', + 'mail2pope.com', + 'mail2popular.com', + 'mail2portugal.com', + 'mail2poseidon.com', + 'mail2potatohead.com', + 'mail2power.com', + 'mail2presbyterian.com', + 'mail2president.com', + 'mail2priest.com', + 'mail2prince.com', + 'mail2princess.com', + 'mail2producer.com', + 'mail2professor.com', + 'mail2protect.com', + 'mail2psychiatrist.com', + 'mail2psycho.com', + 'mail2psychologist.com', + 'mail2qatar.com', + 'mail2queen.com', + 'mail2rabbi.com', + 'mail2race.com', + 'mail2racer.com', + 'mail2rachel.com', + 'mail2rage.com', + 'mail2rainmaker.com', + 'mail2ralph.com', + 'mail2randy.com', + 'mail2rap.com', + 'mail2rare.com', + 'mail2rave.com', + 'mail2ray.com', + 'mail2raymond.com', + 'mail2realtor.com', + 'mail2rebecca.com', + 'mail2recruiter.com', + 'mail2recycle.com', + 'mail2redhead.com', + 'mail2reed.com', + 'mail2reggie.com', + 'mail2register.com', + 'mail2rent.com', + 'mail2republican.com', + 'mail2resort.com', + 'mail2rex.com', + 'mail2rhodeisland.com', + 'mail2rich.com', + 'mail2richard.com', + 'mail2ricky.com', + 'mail2ride.com', + 'mail2riley.com', + 'mail2rita.com', + 'mail2rob.com', + 'mail2robert.com', + 'mail2roberta.com', + 'mail2robin.com', + 'mail2rock.com', + 'mail2rocker.com', + 'mail2rod.com', + 'mail2rodney.com', + 'mail2romania.com', + 'mail2rome.com', + 'mail2ron.com', + 'mail2ronald.com', + 'mail2ronnie.com', + 'mail2rose.com', + 'mail2rosie.com', + 'mail2roy.com', + 'mail2rss.org', + 'mail2rudy.com', + 'mail2rugby.com', + 'mail2runner.com', + 'mail2russell.com', + 'mail2russia.com', + 'mail2russian.com', + 'mail2rusty.com', + 'mail2ruth.com', + 'mail2rwanda.com', + 'mail2ryan.com', + 'mail2sa.com', + 'mail2sabrina.com', + 'mail2safe.com', + 'mail2sagittarius.com', + 'mail2sail.com', + 'mail2sailor.com', + 'mail2sal.com', + 'mail2salaam.com', + 'mail2sam.com', + 'mail2samantha.com', + 'mail2samoa.com', + 'mail2samurai.com', + 'mail2sandra.com', + 'mail2sandy.com', + 'mail2sanfrancisco.com', + 'mail2sanmarino.com', + 'mail2santa.com', + 'mail2sara.com', + 'mail2sarah.com', + 'mail2sat.com', + 'mail2saturn.com', + 'mail2saudi.com', + 'mail2saudiarabia.com', + 'mail2save.com', + 'mail2savings.com', + 'mail2school.com', + 'mail2scientist.com', + 'mail2scorpio.com', + 'mail2scott.com', + 'mail2sean.com', + 'mail2search.com', + 'mail2seattle.com', + 'mail2secretagent.com', + 'mail2senate.com', + 'mail2senegal.com', + 'mail2sensual.com', + 'mail2seth.com', + 'mail2sevenseas.com', + 'mail2sexy.com', + 'mail2seychelles.com', + 'mail2shane.com', + 'mail2sharon.com', + 'mail2shawn.com', + 'mail2ship.com', + 'mail2shirley.com', + 'mail2shoot.com', + 'mail2shuttle.com', + 'mail2sierraleone.com', + 'mail2simon.com', + 'mail2singapore.com', + 'mail2single.com', + 'mail2site.com', + 'mail2skater.com', + 'mail2skier.com', + 'mail2sky.com', + 'mail2sleek.com', + 'mail2slim.com', + 'mail2slovakia.com', + 'mail2slovenia.com', + 'mail2smile.com', + 'mail2smith.com', + 'mail2smooth.com', + 'mail2soccer.com', + 'mail2soccerfan.com', + 'mail2socialist.com', + 'mail2soldier.com', + 'mail2somalia.com', + 'mail2son.com', + 'mail2song.com', + 'mail2sos.com', + 'mail2sound.com', + 'mail2southafrica.com', + 'mail2southamerica.com', + 'mail2southcarolina.com', + 'mail2southdakota.com', + 'mail2southkorea.com', + 'mail2southpole.com', + 'mail2spain.com', + 'mail2spanish.com', + 'mail2spare.com', + 'mail2spectrum.com', + 'mail2splash.com', + 'mail2sponsor.com', + 'mail2sports.com', + 'mail2srilanka.com', + 'mail2stacy.com', + 'mail2stan.com', + 'mail2stanley.com', + 'mail2star.com', + 'mail2state.com', + 'mail2stephanie.com', + 'mail2steve.com', + 'mail2steven.com', + 'mail2stewart.com', + 'mail2stlouis.com', + 'mail2stock.com', + 'mail2stockholm.com', + 'mail2stockmarket.com', + 'mail2storage.com', + 'mail2store.com', + 'mail2strong.com', + 'mail2student.com', + 'mail2studio.com', + 'mail2studio54.com', + 'mail2stuntman.com', + 'mail2subscribe.com', + 'mail2sudan.com', + 'mail2superstar.com', + 'mail2surfer.com', + 'mail2suriname.com', + 'mail2susan.com', + 'mail2suzie.com', + 'mail2swaziland.com', + 'mail2sweden.com', + 'mail2sweetheart.com', + 'mail2swim.com', + 'mail2swimmer.com', + 'mail2swiss.com', + 'mail2switzerland.com', + 'mail2sydney.com', + 'mail2sylvia.com', + 'mail2syria.com', + 'mail2taboo.com', + 'mail2taiwan.com', + 'mail2tajikistan.com', + 'mail2tammy.com', + 'mail2tango.com', + 'mail2tanya.com', + 'mail2tanzania.com', + 'mail2tara.com', + 'mail2taurus.com', + 'mail2taxi.com', + 'mail2taxidermist.com', + 'mail2taylor.com', + 'mail2taz.com', + 'mail2teacher.com', + 'mail2technician.com', + 'mail2ted.com', + 'mail2telephone.com', + 'mail2teletubbie.com', + 'mail2tenderness.com', + 'mail2tennessee.com', + 'mail2tennis.com', + 'mail2tennisfan.com', + 'mail2terri.com', + 'mail2terry.com', + 'mail2test.com', + 'mail2texas.com', + 'mail2thailand.com', + 'mail2therapy.com', + 'mail2think.com', + 'mail2tickets.com', + 'mail2tiffany.com', + 'mail2tim.com', + 'mail2time.com', + 'mail2timothy.com', + 'mail2tina.com', + 'mail2titanic.com', + 'mail2toby.com', + 'mail2todd.com', + 'mail2togo.com', + 'mail2tom.com', + 'mail2tommy.com', + 'mail2tonga.com', + 'mail2tony.com', + 'mail2touch.com', + 'mail2tourist.com', + 'mail2tracey.com', + 'mail2tracy.com', + 'mail2tramp.com', + 'mail2travel.com', + 'mail2traveler.com', + 'mail2travis.com', + 'mail2trekkie.com', + 'mail2trex.com', + 'mail2triallawyer.com', + 'mail2trick.com', + 'mail2trillionaire.com', + 'mail2troy.com', + 'mail2truck.com', + 'mail2trump.com', + 'mail2try.com', + 'mail2tunisia.com', + 'mail2turbo.com', + 'mail2turkey.com', + 'mail2turkmenistan.com', + 'mail2tv.com', + 'mail2tycoon.com', + 'mail2tyler.com', + 'mail2u4me.com', + 'mail2uae.com', + 'mail2uganda.com', + 'mail2uk.com', + 'mail2ukraine.com', + 'mail2uncle.com', + 'mail2unsubscribe.com', + 'mail2uptown.com', + 'mail2uruguay.com', + 'mail2usa.com', + 'mail2utah.com', + 'mail2uzbekistan.com', + 'mail2v.com', + 'mail2vacation.com', + 'mail2valentines.com', + 'mail2valerie.com', + 'mail2valley.com', + 'mail2vamoose.com', + 'mail2vanessa.com', + 'mail2vanuatu.com', + 'mail2venezuela.com', + 'mail2venous.com', + 'mail2venus.com', + 'mail2vermont.com', + 'mail2vickie.com', + 'mail2victor.com', + 'mail2victoria.com', + 'mail2vienna.com', + 'mail2vietnam.com', + 'mail2vince.com', + 'mail2virginia.com', + 'mail2virgo.com', + 'mail2visionary.com', + 'mail2vodka.com', + 'mail2volleyball.com', + 'mail2waiter.com', + 'mail2wallstreet.com', + 'mail2wally.com', + 'mail2walter.com', + 'mail2warren.com', + 'mail2washington.com', + 'mail2wave.com', + 'mail2way.com', + 'mail2waycool.com', + 'mail2wayne.com', + 'mail2webmaster.com', + 'mail2webtop.com', + 'mail2webtv.com', + 'mail2weird.com', + 'mail2wendell.com', + 'mail2wendy.com', + 'mail2westend.com', + 'mail2westvirginia.com', + 'mail2whether.com', + 'mail2whip.com', + 'mail2white.com', + 'mail2whitehouse.com', + 'mail2whitney.com', + 'mail2why.com', + 'mail2wilbur.com', + 'mail2wild.com', + 'mail2willard.com', + 'mail2willie.com', + 'mail2wine.com', + 'mail2winner.com', + 'mail2wired.com', + 'mail2wisconsin.com', + 'mail2woman.com', + 'mail2wonder.com', + 'mail2world.com', + 'mail2worship.com', + 'mail2wow.com', + 'mail2www.com', + 'mail2wyoming.com', + 'mail2xfiles.com', + 'mail2xox.com', + 'mail2yachtclub.com', + 'mail2yahalla.com', + 'mail2yemen.com', + 'mail2yes.com', + 'mail2yugoslavia.com', + 'mail2zack.com', + 'mail2zambia.com', + 'mail2zenith.com', + 'mail2zephir.com', + 'mail2zeus.com', + 'mail2zipper.com', + 'mail2zoo.com', + 'mail2zoologist.com', + 'mail2zurich.com', + 'mail3000.com', + 'mail333.com', + 'mail4trash.com', + 'mail4u.info', + 'mailandftp.com', + 'mailandnews.com', + 'mailas.com', + 'mailasia.com', + 'mailbolt.com', + 'mailbomb.net', + 'mailboom.com', + 'mailbox.as', + 'mailbox.co.za', + 'mailbox.gr', + 'mailbox.hu', + 'mailbox72.biz', + 'mailbox80.biz', + 'mailbr.com.br', + 'mailc.net', + 'mailcan.com', + 'mailcat.biz', + 'mailcc.com', + 'mailchoose.co', + 'mailcity.com', + 'mailclub.fr', + 'mailclub.net', + 'maildrop.cc', + 'maildrop.gq', + 'maildx.com', + 'mailed.ro', + 'mailexcite.com', + 'mailfa.tk', + 'mailfence.com', + 'mailforce.net', + 'mailforspam.com', + 'mailfree.gq', + 'mailfs.com', + 'mailftp.com', + 'mailgenie.net', + 'mailguard.me', + 'mailhaven.com', + 'mailhood.com', + 'mailimate.com', + 'mailinator.com', + 'mailinator.org', + 'mailinator.us', + 'mailinblack.com', + 'mailingaddress.org', + 'mailingweb.com', + 'mailisent.com', + 'mailismagic.com', + 'mailite.com', + 'mailmate.com', + 'mailme.dk', + 'mailme.gq', + 'mailme24.com', + 'mailmight.com', + 'mailmij.nl', + 'mailnator.com', + 'mailnew.com', + 'mailops.com', + 'mailoye.com', + 'mailpanda.com', + 'mailpick.biz', + 'mailpokemon.com', + 'mailpost.zzn.com', + 'mailpride.com', + 'mailproxsy.com', + 'mailpuppy.com', + 'mailquack.com', + 'mailrock.biz', + 'mailroom.com', + 'mailru.com', + 'mailsac.com', + 'mailseal.de', + 'mailsent.net', + 'mailservice.ms', + 'mailshuttle.com', + 'mailslapping.com', + 'mailstart.com', + 'mailstartplus.com', + 'mailsurf.com', + 'mailtag.com', + 'mailtemp.info', + 'mailto.de', + 'mailtothis.com', + 'mailueberfall.de', + 'mailup.net', + 'mailwire.com', + 'mailworks.org', + 'mailzi.ru', + 'mailzilla.org', + 'maktoob.com', + 'malayalamtelevision.net', + 'maltesemail.com', + 'mamber.net', + 'manager.de', + 'mancity.net', + 'mantrafreenet.com', + 'mantramail.com', + 'mantraonline.com', + 'manybrain.com', + 'marchmail.com', + 'mariahc.com', + 'marijuana.com', + 'marijuana.nl', + 'married-not.com', + 'marsattack.com', + 'martindalemail.com', + 'mash4077.com', + 'masrawy.com', + 'matmail.com', + 'mauimail.com', + 'mauritius.com', + 'maxleft.com', + 'maxmail.co.uk', + 'mbox.com.au', + 'mchsi.com', + 'me-mail.hu', + 'me.com', + 'medical.net.au', + 'medscape.com', + 'meetingmall.com', + 'megago.com', + 'megamail.pt', + 'megapoint.com', + 'mehrani.com', + 'mehtaweb.com', + 'meine-dateien.info', + 'meine-diashow.de', + 'meine-fotos.info', + 'meine-urlaubsfotos.de', + 'mekhong.com', + 'melodymail.com', + 'meloo.com', + 'merda.flu.cc', + 'merda.igg.biz', + 'merda.nut.cc', + 'merda.usa.cc', + 'message.hu', + 'message.myspace.com', + 'messages.to', + 'metacrawler.com', + 'metalfan.com', + 'metaping.com', + 'metta.lk', + 'mexicomail.com', + 'mezimages.net', + 'mfsa.ru', + 'mierdamail.com', + 'miesto.sk', + 'mighty.co.za', + 'migmail.net', + 'migmail.pl', + 'migumail.com', + 'miho-nakayama.com', + 'mikrotamanet.com', + 'millionaireintraining.com', + 'millionairemail.com', + 'milmail.com', + 'mindless.com', + 'mindspring.com', + 'minister.com', + 'misery.net', + 'mittalweb.com', + 'mixmail.com', + 'mjfrogmail.com', + 'ml1.net', + 'mlb.bounce.ed10.net', + 'mm.st', + 'mns.ru', + 'moakt.com', + 'mobilbatam.com', + 'mobileninja.co.uk', + 'mochamail.com', + 'mohammed.com', + 'mohmal.com', + 'moldova.cc', + 'moldova.com', + 'moldovacc.com', + 'momslife.com', + 'monemail.com', + 'money.net', + 'montevideo.com.uy', + 'monumentmail.com', + 'moonman.com', + 'moose-mail.com', + 'mor19.uu.gl', + 'mortaza.com', + 'mosaicfx.com', + 'moscowmail.com', + 'most-wanted.com', + 'mostlysunny.com', + 'motormania.com', + 'movemail.com', + 'movieluver.com', + 'mox.pp.ua', + 'mp4.it', + 'mr-potatohead.com', + 'mscold.com', + 'msgbox.com', + 'msn.cn', + 'msn.com', + 'msn.nl', + 'mt2015.com', + 'mt2016.com', + 'mttestdriver.com', + 'muehlacker.tk', + 'muell.icu', + 'muellemail.com', + 'muellmail.com', + 'mundomail.net', + 'munich.com', + 'music.com', + 'musician.org', + 'musicscene.org', + 'muskelshirt.de', + 'muslim.com', + 'muslimsonline.com', + 'mutantweb.com', + 'mvrht.com', + 'my.com', + 'my10minutemail.com', + 'mybox.it', + 'mycabin.com', + 'mycity.com', + 'mycool.com', + 'mydomain.com', + 'mydotcomaddress.com', + 'myfamily.com', + 'myfastmail.com', + 'mygo.com', + 'myiris.com', + 'mymacmail.com', + 'mynamedot.com', + 'mynet.com', + 'mynetaddress.com', + 'mynetstore.de', + 'myownemail.com', + 'myownfriends.com', + 'mypacks.net', + 'mypad.com', + 'mypersonalemail.com', + 'myplace.com', + 'myrambler.ru', + 'myrealbox.com', + 'myremarq.com', + 'myself.com', + 'myspaceinc.net', + 'myspamless.com', + 'mystupidjob.com', + 'mytemp.email', + 'mythirdage.com', + 'myway.com', + 'myworldmail.com', + 'n2.com', + 'n2baseball.com', + 'n2business.com', + 'n2mail.com', + 'n2soccer.com', + 'n2software.com', + 'nabc.biz', + 'nafe.com', + 'nagpal.net', + 'nakedgreens.com', + 'name.com', + 'nameplanet.com', + 'nandomail.com', + 'naplesnews.net', + 'naseej.com', + 'nativestar.net', + 'nativeweb.net', + 'naui.net', + 'naver.com', + 'navigator.lv', + 'navy.org', + 'naz.com', + 'nc.rr.com', + 'nchoicemail.com', + 'neeva.net', + 'nemra1.com', + 'nenter.com', + 'neo.rr.com', + 'nervhq.org', + 'net-c.be', + 'net-c.ca', + 'net-c.cat', + 'net-c.com', + 'net-c.es', + 'net-c.fr', + 'net-c.it', + 'net-c.lu', + 'net-c.nl', + 'net-c.pl', + 'net-pager.net', + 'net-shopping.com', + 'net4b.pt', + 'net4you.at', + 'netbounce.com', + 'netbroadcaster.com', + 'netby.dk', + 'netc.eu', + 'netc.fr', + 'netc.it', + 'netc.lu', + 'netc.pl', + 'netcenter-vn.net', + 'netcmail.com', + 'netcourrier.com', + 'netexecutive.com', + 'netexpressway.com', + 'netgenie.com', + 'netian.com', + 'netizen.com.ar', + 'netlane.com', + 'netlimit.com', + 'netmongol.com', + 'netnet.com.sg', + 'netnoir.net', + 'netpiper.com', + 'netposta.net', + 'netralink.com', + 'netscape.net', + 'netscapeonline.co.uk', + 'netspace.net.au', + 'netspeedway.com', + 'netsquare.com', + 'netster.com', + 'nettaxi.com', + 'nettemail.com', + 'netterchef.de', + 'netti.fi', + 'netzero.com', + 'netzero.net', + 'netzidiot.de', + 'neue-dateien.de', + 'neuro.md', + 'newmail.com', + 'newmail.net', + 'newmail.ru', + 'newsboysmail.com', + 'newyork.com', + 'nextmail.ru', + 'nexxmail.com', + 'nfmail.com', + 'nicebush.com', + 'nicegal.com', + 'nicholastse.net', + 'nicolastse.com', + 'nightmail.com', + 'nikopage.com', + 'nimail.com', + 'ninfan.com', + 'nirvanafan.com', + 'nmail.cf', + 'noavar.com', + 'nonpartisan.com', + 'nonspam.eu', + 'nonspammer.de', + 'norika-fujiwara.com', + 'norikomail.com', + 'northgates.net', + 'nospammail.net', + 'nospamthanks.info', + 'nowhere.org', + 'ntelos.net', + 'ntlhelp.net', + 'ntlworld.com', + 'ntscan.com', + 'null.net', + 'nullbox.info', + 'nur-fuer-spam.de', + 'nus.edu.sg', + 'nwldx.com', + 'nwytg.net', + 'nxt.ru', + 'ny.com', + 'nybella.com', + 'nyc.com', + 'nycmail.com', + 'nzoomail.com', + 'o-tay.com', + 'o2.co.uk', + 'oaklandas-fan.com', + 'oath.com', + 'oceanfree.net', + 'odaymail.com', + 'oddpost.com', + 'odmail.com', + 'office-dateien.de', + 'office-email.com', + 'offroadwarrior.com', + 'oicexchange.com', + 'oida.icu', + 'oikrach.com', + 'okbank.com', + 'okhuman.com', + 'okmad.com', + 'okmagic.com', + 'okname.net', + 'okuk.com', + 'oldies104mail.com', + 'ole.com', + 'olemail.com', + 'olympist.net', + 'olypmall.ru', + 'omaninfo.com', + 'omen.ru', + 'onebox.com', + 'onenet.com.ar', + 'oneoffmail.com', + 'onet.com.pl', + 'onet.eu', + 'onet.pl', + 'oninet.pt', + 'online.ie', + 'online.ms', + 'online.nl', + 'onlinewiz.com', + 'onmilwaukee.com', + 'onobox.com', + 'op.pl', + 'opayq.com', + 'openmailbox.org', + 'operafan.com', + 'operamail.com', + 'opoczta.pl', + 'optician.com', + 'optonline.net', + 'optusnet.com.au', + 'orange.fr', + 'orbitel.bg', + 'orgmail.net', + 'orthodontist.net', + 'osite.com.br', + 'oso.com', + 'otakumail.com', + 'our-computer.com', + 'our-office.com', + 'our.st', + 'ourbrisbane.com', + 'ourklips.com', + 'ournet.md', + 'outgun.com', + 'outlawspam.com', + 'outlook.at', + 'outlook.be', + 'outlook.cl', + 'outlook.co.id', + 'outlook.co.il', + 'outlook.co.nz', + 'outlook.co.th', + 'outlook.com', + 'outlook.com.au', + 'outlook.com.br', + 'outlook.com.gr', + 'outlook.com.pe', + 'outlook.com.tr', + 'outlook.com.vn', + 'outlook.cz', + 'outlook.de', + 'outlook.dk', + 'outlook.es', + 'outlook.fr', + 'outlook.hu', + 'outlook.ie', + 'outlook.in', + 'outlook.it', + 'outlook.jp', + 'outlook.kr', + 'outlook.lv', + 'outlook.my', + 'outlook.nl', + 'outlook.ph', + 'outlook.pt', + 'outlook.sa', + 'outlook.sg', + 'outlook.sk', + 'over-the-rainbow.com', + 'ownmail.net', + 'ozbytes.net.au', + 'ozemail.com.au', + 'pacbell.net', + 'pacific-ocean.com', + 'pacific-re.com', + 'pacificwest.com', + 'packersfan.com', + 'pagina.de', + 'pagons.org', + 'pakistanmail.com', + 'pakistanoye.com', + 'palestinemail.com', + 'pandora.be', + 'papierkorb.me', + 'parkjiyoon.com', + 'parsmail.com', + 'partlycloudy.com', + 'partybombe.de', + 'partyheld.de', + 'partynight.at', + 'parvazi.com', + 'passwordmail.com', + 'pathfindermail.com', + 'pconnections.net', + 'pcpostal.com', + 'pcsrock.com', + 'pcusers.otherinbox.com', + 'pediatrician.com', + 'penpen.com', + 'peoplepc.com', + 'peopleweb.com', + 'pepbot.com', + 'perfectmail.com', + 'perso.be', + 'personal.ro', + 'personales.com', + 'petlover.com', + 'petml.com', + 'pettypool.com', + 'pezeshkpour.com', + 'pfui.ru', + 'phayze.com', + 'phone.net', + 'photo-impact.eu', + 'photographer.net', + 'phpbb.uu.gl', + 'phreaker.net', + 'phus8kajuspa.cu.cc', + 'physicist.net', + 'pianomail.com', + 'pickupman.com', + 'picusnet.com', + 'pigpig.net', + 'pinoymail.com', + 'piracha.net', + 'pisem.net', + 'pjjkp.com', + 'planet.nl', + 'planetaccess.com', + 'planetarymotion.net', + 'planetearthinter.net', + 'planetmail.com', + 'planetmail.net', + 'planetout.com', + 'plasa.com', + 'playersodds.com', + 'playful.com', + 'playstation.sony.com', + 'plus.com', + 'plus.google.com', + 'plusmail.com.br', + 'pmail.net', + 'pobox.hu', + 'pobox.sk', + 'pochta.ru', + 'poczta.fm', + 'poczta.onet.pl', + 'poetic.com', + 'pokemail.net', + 'pokemonpost.com', + 'pokepost.com', + 'polandmail.com', + 'polbox.com', + 'policeoffice.com', + 'politician.com', + 'polizisten-duzer.de', + 'polyfaust.com', + 'pool-sharks.com', + 'poond.com', + 'popaccount.com', + 'popmail.com', + 'popsmail.com', + 'popstar.com', + 'portugalmail.com', + 'portugalmail.pt', + 'portugalnet.com', + 'positive-thinking.com', + 'post.com', + 'post.cz', + 'post.sk', + 'posta.ro', + 'postaccesslite.com', + 'postafree.com', + 'postaweb.com', + 'posteo.at', + 'posteo.be', + 'posteo.ch', + 'posteo.cl', + 'posteo.co', + 'posteo.de', + 'posteo.dk', + 'posteo.es', + 'posteo.gl', + 'posteo.net', + 'posteo.no', + 'posteo.us', + 'postfach.cc', + 'postinbox.com', + 'postino.ch', + 'postmark.net', + 'postmaster.co.uk', + 'postmaster.twitter.com', + 'postpro.net', + 'pousa.com', + 'powerfan.com', + 'pp.inet.fi', + 'praize.com', + 'premium-mail.fr', + 'premiumservice.com', + 'presidency.com', + 'press.co.jp', + 'priest.com', + 'primposta.com', + 'primposta.hu', + 'privy-mail.com', + 'privymail.de', + 'pro.hu', + 'probemail.com', + 'prodigy.net', + 'progetplus.it', + 'programist.ru', + 'programmer.net', + 'programozo.hu', + 'proinbox.com', + 'project2k.com', + 'promessage.com', + 'prontomail.com', + 'protestant.com', + 'protonmail.com', + 'prydirect.info', + 'psv-supporter.com', + 'ptd.net', + 'public-files.de', + 'public.usa.com', + 'publicist.com', + 'pulp-fiction.com', + 'punkass.com', + 'purpleturtle.com', + 'put2.net', + 'pwrby.com', + 'q.com', + 'qatarmail.com', + 'qmail.com', + 'qprfans.com', + 'qq.com', + 'qrio.com', + 'quackquack.com', + 'quakemail.com', + 'qualityservice.com', + 'quantentunnel.de', + 'qudsmail.com', + 'quepasa.com', + 'quickhosts.com', + 'quickmail.nl', + 'quicknet.nl', + 'quickwebmail.com', + 'quiklinks.com', + 'quikmail.com', + 'qv7.info', + 'qwest.net', + 'qwestoffice.net', + 'r-o-o-t.com', + 'raakim.com', + 'racedriver.com', + 'racefanz.com', + 'racingfan.com.au', + 'racingmail.com', + 'radicalz.com', + 'radiku.ye.vc', + 'radiologist.net', + 'ragingbull.com', + 'ralib.com', + 'rambler.ru', + 'ranmamail.com', + 'rastogi.net', + 'ratt-n-roll.com', + 'rattle-snake.com', + 'raubtierbaendiger.de', + 'ravearena.com', + 'ravemail.com', + 'razormail.com', + 'rccgmail.org', + 'rcn.com', + 'realemail.net', + 'reality-concept.club', + 'reallyfast.biz', + 'reallyfast.info', + 'reallymymail.com', + 'realradiomail.com', + 'realtyagent.com', + 'reborn.com', + 'reconmail.com', + 'recycler.com', + 'recyclermail.com', + 'rediff.com', + 'rediffmail.com', + 'rediffmailpro.com', + 'rednecks.com', + 'redseven.de', + 'redsfans.com', + 'regbypass.com', + 'reggaefan.com', + 'registerednurses.com', + 'regspaces.tk', + 'reincarnate.com', + 'religious.com', + 'remail.ga', + 'renren.com', + 'repairman.com', + 'reply.hu', + 'reply.ticketmaster.com', + 'representative.com', + 'rescueteam.com', + 'resgedvgfed.tk', + 'resource.calendar.google.com', + 'resumemail.com', + 'rezai.com', + 'rhyta.com', + 'richmondhill.com', + 'rickymail.com', + 'rin.ru', + 'riopreto.com.br', + 'rklips.com', + 'rn.com', + 'ro.ru', + 'roadrunner.com', + 'roanokemail.com', + 'rock.com', + 'rocketmail.com', + 'rocketship.com', + 'rockfan.com', + 'rodrun.com', + 'rogers.com', + 'rome.com', + 'roosh.com', + 'rootprompt.org', + 'roughnet.com', + 'royal.net', + 'rr.com', + 'rrohio.com', + 'rsub.com', + 'rubyridge.com', + 'runbox.com', + 'rushpost.com', + 'ruttolibero.com', + 'rvshop.com', + 's-mail.com', + 'sabreshockey.com', + 'sacbeemail.com', + 'saeuferleber.de', + 'safe-mail.net', + 'safrica.com', + 'sagra.lu', + 'sags-per-mail.de', + 'sailormoon.com', + 'saintly.com', + 'saintmail.net', + 'sale-sale-sale.com', + 'salehi.net', + 'salesperson.net', + 'samerica.com', + 'samilan.net', + 'sammimail.com', + 'sandelf.de', + 'sanfranmail.com', + 'sanook.com', + 'sapo.pt', + 'saudia.com', + 'savelife.ml', + 'sayhi.net', + 'saynotospams.com', + 'sbcglbal.net', + 'sbcglobal.com', + 'sbcglobal.net', + 'scandalmail.com', + 'scarlet.nl', + 'schafmail.de', + 'schizo.com', + 'schmusemail.de', + 'schoolemail.com', + 'schoolmail.com', + 'schoolsucks.com', + 'schreib-doch-mal-wieder.de', + 'schweiz.org', + 'sci.fi', + 'scientist.com', + 'scifianime.com', + 'scotland.com', + 'scotlandmail.com', + 'scottishmail.co.uk', + 'scottsboro.org', + 'scubadiving.com', + 'seanet.com', + 'search.ua', + 'searchwales.com', + 'sebil.com', + 'seckinmail.com', + 'secret-police.com', + 'secretary.net', + 'secretservices.net', + 'secure-mail.biz', + 'secure-mail.cc', + 'seductive.com', + 'seekstoyboy.com', + 'seguros.com.br', + 'selfdestructingmail.com', + 'send.hu', + 'sendme.cz', + 'sendspamhere.com', + 'sent.as', + 'sent.at', + 'sent.com', + 'sentrismail.com', + 'serga.com.ar', + 'servemymail.com', + 'servermaps.net', + 'sesmail.com', + 'sexmagnet.com', + 'seznam.cz', + 'shahweb.net', + 'shaniastuff.com', + 'shared-files.de', + 'sharedmailbox.org', + 'sharmaweb.com', + 'shaw.ca', + 'she.com', + 'shieldedmail.com', + 'shinedyoureyes.com', + 'shitaway.cf', + 'shitaway.cu.cc', + 'shitaway.ga', + 'shitaway.gq', + 'shitaway.ml', + 'shitaway.tk', + 'shitaway.usa.cc', + 'shitmail.de', + 'shitmail.org', + 'shitware.nl', + 'shockinmytown.cu.cc', + 'shootmail.com', + 'shortmail.com', + 'shotgun.hu', + 'showslow.de', + 'shuf.com', + 'sialkotcity.com', + 'sialkotian.com', + 'sialkotoye.com', + 'sify.com', + 'silkroad.net', + 'sina.cn', + 'sina.com', + 'sinamail.com', + 'singapore.com', + 'singles4jesus.com', + 'singmail.com', + 'singnet.com.sg', + 'sinnlos-mail.de', + 'siteposter.net', + 'skafan.com', + 'skeefmail.com', + 'skim.com', + 'skizo.hu', + 'skrx.tk', + 'sky.com', + 'skynet.be', + 'slamdunkfan.com', + 'slave-auctions.net', + 'slingshot.com', + 'slippery.email', + 'slipry.net', + 'slo.net', + 'slotter.com', + 'smap.4nmv.ru', + 'smapxsmap.net', + 'smashmail.de', + 'smellrear.com', + 'smileyface.comsmithemail.net', + 'smoothmail.com', + 'sms.at', + 'snail-mail.net', + 'snakebite.com', + 'snakemail.com', + 'sndt.net', + 'sneakemail.com', + 'snet.net', + 'sniper.hu', + 'snkmail.com', + 'snoopymail.com', + 'snowboarding.com', + 'snowdonia.net', + 'socamail.com', + 'socceramerica.net', + 'soccermail.com', + 'soccermomz.com', + 'social-mailer.tk', + 'socialworker.net', + 'sociologist.com', + 'sofort-mail.de', + 'sofortmail.de', + 'softhome.net', + 'sogou.com', + 'sohu.com', + 'sol.dk', + 'solar-impact.pro', + 'solcon.nl', + 'soldier.hu', + 'solution4u.com', + 'solvemail.info', + 'songwriter.net', + 'sonnenkinder.org', + 'soodomail.com', + 'soon.com', + 'soulfoodcookbook.com', + 'sp.nl', + 'space-bank.com', + 'space-man.com', + 'space-ship.com', + 'space-travel.com', + 'space.com', + 'spacemart.com', + 'spacetowns.com', + 'spacewar.com', + 'spainmail.com', + 'spam.2012-2016.ru', + 'spam.care', + 'spamavert.com', + 'spambob.com', + 'spambob.org', + 'spambog.net', + 'spambooger.com', + 'spamcero.com', + 'spamdecoy.net', + 'spameater.com', + 'spameater.org', + 'spamex.com', + 'spamfree24.info', + 'spamfree24.net', + 'spamgoes.in', + 'spaminator.de', + 'spamkill.info', + 'spaml.com', + 'spamoff.de', + 'spamstack.net', + 'spartapiet.com', + 'spazmail.com', + 'speedemail.net', + 'speedpost.net', + 'speedrules.com', + 'speedrulz.com', + 'speedymail.org', + 'sperke.net', + 'spils.com', + 'spinfinder.com', + 'spl.at', + 'spoko.pl', + 'spoofmail.de', + 'sportemail.com', + 'sportsmail.com', + 'sporttruckdriver.com', + 'spray.no', + 'spray.se', + 'spybox.de', + 'spymac.com', + 'sraka.xyz', + 'srilankan.net', + 'ssl-mail.com', + 'st-davids.net', + 'stade.fr', + 'stalag13.com', + 'stargateradio.com', + 'starmail.com', + 'starmail.org', + 'starmedia.com', + 'starplace.com', + 'starspath.com', + 'start.com.au', + 'startkeys.com', + 'stinkefinger.net', + 'stipte.nl', + 'stoned.com', + 'stones.com', + 'stop-my-spam.pp.ua', + 'stopdropandroll.com', + 'storksite.com', + 'streber24.de', + 'streetwisemail.com', + 'stribmail.com', + 'strompost.com', + 'strongguy.com', + 'student.su', + 'studentcenter.org', + 'stuffmail.de', + 'subram.com', + 'sudanmail.net', + 'sudolife.me', + 'sudolife.net', + 'sudomail.biz', + 'sudomail.com', + 'sudomail.net', + 'sudoverse.com', + 'sudoverse.net', + 'sudoweb.net', + 'sudoworld.com', + 'sudoworld.net', + 'suhabi.com', + 'suisse.org', + 'sukhumvit.net', + 'sunpoint.net', + 'sunrise-sunset.com', + 'sunsgame.com', + 'sunumail.sn', + 'suomi24.fi', + 'superdada.com', + 'supereva.it', + 'supermail.ru', + 'superrito.com', + 'superstachel.de', + 'surat.com', + 'surf3.net', + 'surfree.com', + 'surfy.net', + 'surgical.net', + 'surimail.com', + 'survivormail.com', + 'susi.ml', + 'svk.jp', + 'swbell.net', + 'sweb.cz', + 'swedenmail.com', + 'sweetville.net', + 'sweetxxx.de', + 'swift-mail.com', + 'swiftdesk.com', + 'swingeasyhithard.com', + 'swingfan.com', + 'swipermail.zzn.com', + 'swirve.com', + 'swissinfo.org', + 'swissmail.com', + 'swissmail.net', + 'switchboardmail.com', + 'switzerland.org', + 'sx172.com', + 'syom.com', + 'syriamail.com', + 't-online.de', + 't.psh.me', + 't2mail.com', + 'tafmail.com', + 'takuyakimura.com', + 'talk21.com', + 'talkcity.com', + 'talkinator.com', + 'tamil.com', + 'tampabay.rr.com', + 'tankpolice.com', + 'tatanova.com', + 'tbwt.com', + 'tcc.on.ca', + 'tds.net', + 'teachermail.net', + 'teachers.org', + 'teamdiscovery.com', + 'teamtulsa.net', + 'tech-center.com', + 'tech4peace.org', + 'techemail.com', + 'techie.com', + 'technisamail.co.za', + 'technologist.com', + 'techscout.com', + 'techspot.com', + 'teenagedirtbag.com', + 'tele2.nl', + 'telebot.com', + 'telefonica.net', + 'teleline.es', + 'telenet.be', + 'telepac.pt', + 'telerymd.com', + 'teleworm.us', + 'telfort.nl', + 'telfortglasvezel.nl', + 'telinco.net', + 'telkom.net', + 'telpage.net', + 'telstra.com', + 'telstra.com.au', + 'temp-mail.com', + 'temp-mail.de', + 'temp.headstrong.de', + 'tempail.com', + 'tempemail.biz', + 'tempmail.us', + 'tempmail2.com', + 'tempmaildemo.com', + 'tempmailer.com', + 'temporarioemail.com.br', + 'temporaryemail.us', + 'tempthe.net', + 'tempymail.com', + 'temtulsa.net', + 'tenchiclub.com', + 'tenderkiss.com', + 'tennismail.com', + 'terminverpennt.de', + 'terra.cl', + 'terra.com', + 'terra.com.ar', + 'terra.com.br', + 'terra.es', + 'test.com', + 'test.de', + 'tfanus.com.er', + 'tfz.net', + 'thai.com', + 'thaimail.com', + 'thaimail.net', + 'thanksnospam.info', + 'the-african.com', + 'the-airforce.com', + 'the-aliens.com', + 'the-american.com', + 'the-animal.com', + 'the-army.com', + 'the-astronaut.com', + 'the-beauty.com', + 'the-big-apple.com', + 'the-biker.com', + 'the-boss.com', + 'the-brazilian.com', + 'the-canadian.com', + 'the-canuck.com', + 'the-captain.com', + 'the-chinese.com', + 'the-country.com', + 'the-cowboy.com', + 'the-davis-home.com', + 'the-dutchman.com', + 'the-eagles.com', + 'the-englishman.com', + 'the-fastest.net', + 'the-fool.com', + 'the-frenchman.com', + 'the-galaxy.net', + 'the-genius.com', + 'the-gentleman.com', + 'the-german.com', + 'the-gremlin.com', + 'the-hooligan.com', + 'the-italian.com', + 'the-japanese.com', + 'the-lair.com', + 'the-madman.com', + 'the-mailinglist.com', + 'the-marine.com', + 'the-master.com', + 'the-mexican.com', + 'the-ministry.com', + 'the-monkey.com', + 'the-newsletter.net', + 'the-pentagon.com', + 'the-police.com', + 'the-prayer.com', + 'the-professional.com', + 'the-quickest.com', + 'the-russian.com', + 'the-snake.com', + 'the-spaceman.com', + 'the-stock-market.com', + 'the-student.net', + 'the-whitehouse.net', + 'the-wild-west.com', + 'the18th.com', + 'thecoolguy.com', + 'thecriminals.com', + 'thedoghousemail.com', + 'thedorm.com', + 'theend.hu', + 'theglobe.com', + 'thegolfcourse.com', + 'thegooner.com', + 'theheadoffice.com', + 'theinternetemail.com', + 'thelanddownunder.com', + 'themail.com', + 'themillionare.net', + 'theoffice.net', + 'theplate.com', + 'thepokerface.com', + 'thepostmaster.net', + 'theraces.com', + 'theracetrack.com', + 'therapist.net', + 'thestreetfighter.com', + 'theteebox.com', + 'thewatercooler.com', + 'thewebpros.co.uk', + 'thewizzard.com', + 'thewizzkid.com', + 'thezhangs.net', + 'thirdage.com', + 'thisgirl.com', + 'thraml.com', + 'throwam.com', + 'thundermail.com', + 'tidni.com', + 'timein.net', + 'tiscali.at', + 'tiscali.be', + 'tiscali.co.uk', + 'tiscali.it', + 'tiscali.lu', + 'tiscali.se', + 'tkcity.com', + 'tmail.ws', + 'toast.com', + 'toke.com', + 'tom.com', + 'toolsource.com', + 'toomail.biz', + 'toothfairy.com', + 'topchat.com', + 'topgamers.co.uk', + 'topletter.com', + 'topmail-files.de', + 'topmail.com.ar', + 'topsurf.com', + 'torchmail.com', + 'torontomail.com', + 'tortenboxer.de', + 'totalmail.de', + 'totalmusic.net', + 'townisp.com', + 'tpg.com.au', + 'trash-amil.com', + 'trash-mail.ga', + 'trash-mail.ml', + 'trash2010.com', + 'trash2011.com', + 'trashdevil.de', + 'trashymail.net', + 'travel.li', + 'trayna.com', + 'trialbytrivia.com', + 'trickmail.net', + 'trimix.cn', + 'tritium.net', + 'trmailbox.com', + 'tropicalstorm.com', + 'truckerz.com', + 'truckracer.com', + 'truckracers.com', + 'trust-me.com', + 'truthmail.com', + 'tsamail.co.za', + 'ttml.co.in', + 'tunisiamail.com', + 'turboprinz.de', + 'turboprinzessin.de', + 'turkey.com', + 'turual.com', + 'tut.by', + 'tvstar.com', + 'twc.com', + 'twcny.com', + 'twinstarsmail.com', + 'tx.rr.com', + 'tycoonmail.com', + 'typemail.com', + 'u14269.ml', + 'u2club.com', + 'ua.fm', + 'uae.ac', + 'uaemail.com', + 'ubbi.com', + 'ubbi.com.br', + 'uboot.com', + 'uk2.net', + 'uk2k.com', + 'uk2net.com', + 'uk7.net', + 'uk8.net', + 'ukbuilder.com', + 'ukcool.com', + 'ukdreamcast.com', + 'ukmail.org', + 'ukmax.com', + 'ukr.net', + 'uku.co.uk', + 'ultapulta.com', + 'ultra.fyi', + 'ultrapostman.com', + 'ummah.org', + 'umpire.com', + 'unbounded.com', + 'unforgettable.com', + 'uni.de', + 'unican.es', + 'unihome.com', + 'unitybox.de', + 'universal.pt', + 'uno.ee', + 'uno.it', + 'unofree.it', + 'unterderbruecke.de', + 'uol.com.ar', + 'uol.com.br', + 'uol.com.co', + 'uol.com.mx', + 'uol.com.ve', + 'uole.com', + 'uole.com.ve', + 'uolmail.com', + 'uomail.com', + 'upc.nl', + 'upcmail.nl', + 'upf.org', + 'uplipht.com', + 'ureach.com', + 'urgentmail.biz', + 'urhen.com', + 'uroid.com', + 'usa.com', + 'usa.net', + 'usaaccess.net', + 'usanetmail.com', + 'used-product.fr', + 'usermail.com', + 'username.e4ward.com', + 'usma.net', + 'usmc.net', + 'uswestmail.net', + 'uymail.com', + 'uyuyuy.com', + 'v-sexi.com', + 'vaasfc4.tk', + 'vahoo.com', + 'valemail.net', + 'vampirehunter.com', + 'varbizmail.com', + 'vcmail.com', + 'velnet.co.uk', + 'velocall.com', + 'verizon.net', + 'verizonmail.com', + 'verlass-mich-nicht.de', + 'versatel.nl', + 'veryfast.biz', + 'veryrealemail.com', + 'veryspeedy.net', + 'vfemail.net', + 'vickaentb.tk', + 'videotron.ca', + 'viditag.com', + 'viewcastmedia.com', + 'viewcastmedia.net', + 'vinbazar.com', + 'violinmakers.co.uk', + 'vip.126.com', + 'vip.21cn.com', + 'vip.citiz.net', + 'vip.gr', + 'vip.onet.pl', + 'vip.qq.com', + 'vip.sina.com', + 'vipmail.ru', + 'virgilio.it', + 'virgin.net', + 'virginbroadband.com.au', + 'virginmedia.com', + 'virtualmail.com', + 'visitmail.com', + 'visitweb.com', + 'visto.com', + 'visualcities.com', + 'vivavelocity.com', + 'vivianhsu.net', + 'vjtimail.com', + 'vkcode.ru', + 'vnet.citiz.net', + 'vnn.vn', + 'vodafone.nl', + 'vodafonethuis.nl', + 'volcanomail.com', + 'vollbio.de', + 'volloeko.de', + 'vomoto.com', + 'vorsicht-bissig.de', + 'vorsicht-scharf.de', + 'vote-democrats.com', + 'vote-hillary.com', + 'vote-republicans.com', + 'vote4gop.org', + 'votenet.com', + 'vp.pl', + 'vr9.com', + 'vubby.com', + 'w3.to', + 'wahoye.com', + 'walala.org', + 'wales2000.net', + 'walkmail.net', + 'walkmail.ru', + 'wam.co.za', + 'wanadoo.es', + 'wanadoo.fr', + 'war-im-urlaub.de', + 'warmmail.com', + 'warpmail.net', + 'warrior.hu', + 'waumail.com', + 'wazabi.club', + 'wbdet.com', + 'wearab.net', + 'web-contact.info', + 'web-emailbox.eu', + 'web-ideal.fr', + 'web-mail.com.ar', + 'web-mail.pp.ua', + 'web-police.com', + 'web.de', + 'webave.com', + 'webcammail.com', + 'webcity.ca', + 'webcontact-france.eu', + 'webdream.com', + 'webindia123.com', + 'webjump.com', + 'webm4il.info', + 'webmail.co.yu', + 'webmail.co.za', + 'webmail.hu', + 'webmails.com', + 'webname.com', + 'webprogramming.com', + 'webstation.com', + 'websurfer.co.za', + 'webtopmail.com', + 'webuser.in', + 'wee.my', + 'weedmail.com', + 'weekmail.com', + 'weekonline.com', + 'wefjo.grn.cc', + 'weg-werf-email.de', + 'wegas.ru', + 'wegwerf-emails.de', + 'wegwerfmail.info', + 'wegwerpmailadres.nl', + 'wehshee.com', + 'weibsvolk.de', + 'weibsvolk.org', + 'weinenvorglueck.de', + 'welsh-lady.com', + 'westnet.com', + 'westnet.com.au', + 'wetrainbayarea.com', + 'wfgdfhj.tk', + 'whale-mail.com', + 'whartontx.com', + 'whatiaas.com', + 'whatpaas.com', + 'wheelweb.com', + 'whipmail.com', + 'whoever.com', + 'whoopymail.com', + 'whtjddn.33mail.com', + 'wi.rr.com', + 'wi.twcbc.com', + 'wickmail.net', + 'wideopenwest.com', + 'wildmail.com', + 'wilemail.com', + 'will-hier-weg.de', + 'windowslive.com', + 'windrivers.net', + 'windstream.net', + 'wingnutz.com', + 'winmail.com.au', + 'winning.com', + 'wir-haben-nachwuchs.de', + 'wir-sind-cool.org', + 'wirsindcool.de', + 'witty.com', + 'wiz.cc', + 'wkbwmail.com', + 'wmail.cf', + 'wo.com.cn', + 'woh.rr.com', + 'wolf-web.com', + 'wolke7.net', + 'wollan.info', + 'wombles.com', + 'women-at-work.org', + 'wongfaye.com', + 'wooow.it', + 'worker.com', + 'workmail.com', + 'worldemail.com', + 'worldnet.att.net', + 'wormseo.cn', + 'wosaddict.com', + 'wouldilie.com', + 'wovz.cu.cc', + 'wowgirl.com', + 'wowmail.com', + 'wowway.com', + 'wp.pl', + 'wptamail.com', + 'wrexham.net', + 'writeme.com', + 'writemeback.com', + 'wrongmail.com', + 'wtvhmail.com', + 'wwdg.com', + 'www.com', + 'www.e4ward.com', + 'www2000.net', + 'wx88.net', + 'wxs.net', + 'x-mail.net', + 'x-networks.net', + 'x5g.com', + 'xagloo.com', + 'xaker.ru', + 'xing886.uu.gl', + 'xmastime.com', + 'xms.nl', + 'xmsg.com', + 'xoom.com', + 'xoxox.cc', + 'xpressmail.zzn.com', + 'xs4all.nl', + 'xsecurity.org', + 'xsmail.com', + 'xtra.co.nz', + 'xuno.com', + 'xww.ro', + 'xy9ce.tk', + 'xyzfree.net', + 'xzapmail.com', + 'y7mail.com', + 'ya.ru', + 'yada-yada.com', + 'yaho.com', + 'yahoo.ae', + 'yahoo.at', + 'yahoo.be', + 'yahoo.ca', + 'yahoo.ch', + 'yahoo.cn', + 'yahoo.co', + 'yahoo.co.id', + 'yahoo.co.il', + 'yahoo.co.in', + 'yahoo.co.jp', + 'yahoo.co.kr', + 'yahoo.co.nz', + 'yahoo.co.th', + 'yahoo.co.uk', + 'yahoo.co.za', + 'yahoo.com', + 'yahoo.com.ar', + 'yahoo.com.au', + 'yahoo.com.br', + 'yahoo.com.cn', + 'yahoo.com.co', + 'yahoo.com.hk', + 'yahoo.com.is', + 'yahoo.com.mx', + 'yahoo.com.my', + 'yahoo.com.ph', + 'yahoo.com.ru', + 'yahoo.com.sg', + 'yahoo.com.tr', + 'yahoo.com.tw', + 'yahoo.com.vn', + 'yahoo.cz', + 'yahoo.de', + 'yahoo.dk', + 'yahoo.es', + 'yahoo.fi', + 'yahoo.fr', + 'yahoo.gr', + 'yahoo.hu', + 'yahoo.ie', + 'yahoo.in', + 'yahoo.it', + 'yahoo.jp', + 'yahoo.nl', + 'yahoo.no', + 'yahoo.pl', + 'yahoo.pt', + 'yahoo.ro', + 'yahoo.ru', + 'yahoo.se', + 'yahoofs.com', + 'yalla.com', + 'yalla.com.lb', + 'yalook.com', + 'yam.com', + 'yandex.com', + 'yandex.pl', + 'yandex.ru', + 'yandex.ua', + 'yapost.com', + 'yapped.net', + 'yawmail.com', + 'yeah.net', + 'yebox.com', + 'yehey.com', + 'yemenmail.com', + 'yepmail.net', + 'yert.ye.vc', + 'yesey.net', + 'yifan.net', + 'ymail.com', + 'yogotemail.com', + 'yomail.info', + 'yopmail.com', + 'yopmail.pp.ua', + 'yopolis.com', + 'yopweb.com', + 'youareadork.com', + 'youmailr.com', + 'your-house.com', + 'your-mail.com', + 'yourinbox.com', + 'yourlifesucks.cu.cc', + 'yourlover.net', + 'yourname.freeservers.com', + 'yournightmare.com', + 'yours.com', + 'yourssincerely.com', + 'yoursubdomain.zzn.com', + 'yourteacher.net', + 'yourwap.com', + 'yuuhuu.net', + 'yyhmail.com', + 'z1p.biz', + 'za.com', + 'zahadum.com', + 'zaktouni.fr', + 'zeepost.nl', + 'zetmail.com', + 'zhaowei.net', + 'zhouemail.510520.org', + 'ziggo.nl', + 'zionweb.org', + 'zip.net', + 'zipido.com', + 'ziplip.com', + 'zipmail.com', + 'zipmail.com.br', + 'zipmax.com', + 'zmail.ru', + 'zoemail.com', + 'zoemail.org', + 'zoho.com', + 'zohomail.com', + 'zomg.info', + 'zonnet.nl', + 'zoominternet.net', + 'zubee.com', + 'zuvio.com', + 'zuzzurello.com', + 'zwallet.com', + 'zweb.in', + 'zxcv.com', + 'zxcvbnm.com', + 'zybermail.com', + 'zydecofan.com', + 'zzn.com', + 'zzom.co.uk', + 'zzz.com', + 'zzz.pl', +]) diff --git a/libs/domains/onboarding/feature/src/lib/step-personalize/step-personalize.spec.tsx b/libs/domains/onboarding/feature/src/lib/step-personalize/step-personalize.spec.tsx index 03d3e98a32e..492e10cf497 100644 --- a/libs/domains/onboarding/feature/src/lib/step-personalize/step-personalize.spec.tsx +++ b/libs/domains/onboarding/feature/src/lib/step-personalize/step-personalize.spec.tsx @@ -45,4 +45,31 @@ describe('StepPersonalize', () => { await waitFor(() => expect(continueButton).toBeEnabled()) }) + + it('should reject personal email addresses', async () => { + const { userEvent } = renderWithProviders( + wrapWithReactHookForm(, { + defaultValues: { + first_name: 'John', + last_name: 'Doe', + user_email: '', + company_name: 'Acme Corp', + }, + }) + ) + + const continueButton = screen.getByRole('button', { name: 'Continue' }) + const emailInput = screen.getByLabelText('Professional email address') + + await userEvent.type(emailInput, 'john.doe@gmail.com') + await userEvent.tab() + + expect(await screen.findByText('Please enter your professional email address.')).toBeInTheDocument() + expect(continueButton).toBeDisabled() + + await userEvent.clear(emailInput) + await userEvent.type(emailInput, 'john.doe@acme.com') + + await waitFor(() => expect(continueButton).toBeEnabled()) + }) }) diff --git a/libs/domains/onboarding/feature/src/lib/step-personalize/step-personalize.tsx b/libs/domains/onboarding/feature/src/lib/step-personalize/step-personalize.tsx index 7d4b701dcd5..e3b3a306b35 100644 --- a/libs/domains/onboarding/feature/src/lib/step-personalize/step-personalize.tsx +++ b/libs/domains/onboarding/feature/src/lib/step-personalize/step-personalize.tsx @@ -1,5 +1,6 @@ import { Controller, useFormContext } from 'react-hook-form' import { Button, Icon, InputText } from '@qovery/shared/ui' +import { isPersonalEmail } from './personal-email-domains' export interface StepPersonalizeProps { onSubmit: () => void @@ -55,11 +56,12 @@ export function StepPersonalize({ onSubmit, authLogout }: StepPersonalizeProps) value: /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i, message: 'Please enter a valid email address.', }, + validate: (value: string) => !isPersonalEmail(value) || 'Please enter your professional email address.', }} render={({ field, fieldState: { error } }) => ( Date: Tue, 7 Jul 2026 09:51:23 +0200 Subject: [PATCH 14/15] fix(organizations): allow custom git branch selection (#2800) --- .../git-branch-settings.spec.tsx | 40 ++++++++++++++++--- .../git-branch-settings.tsx | 26 ++++++++++-- 2 files changed, 56 insertions(+), 10 deletions(-) diff --git a/libs/domains/organizations/feature/src/lib/git-branch-settings/git-branch-settings.spec.tsx b/libs/domains/organizations/feature/src/lib/git-branch-settings/git-branch-settings.spec.tsx index 73c59be1470..f1f67090ad5 100644 --- a/libs/domains/organizations/feature/src/lib/git-branch-settings/git-branch-settings.spec.tsx +++ b/libs/domains/organizations/feature/src/lib/git-branch-settings/git-branch-settings.spec.tsx @@ -1,27 +1,55 @@ import { wrapWithReactHookForm } from '__tests__/utils/wrap-with-react-hook-form' -import { renderWithProviders } from '@qovery/shared/util-tests' +import { renderWithProviders, screen } from '@qovery/shared/util-tests' import { GitBranchSettings } from './git-branch-settings' +let mockBranches: { name: string }[] = [ + { + name: 'main', + }, +] + jest.mock('../hooks/use-branches/use-branches', () => { return { ...jest.requireActual('../hooks/use-branches/use-branches'), useBranches: () => ({ isLoading: false, isRefetching: false, - data: [ - { - name: 'main', - }, - ], + data: mockBranches, }), } }) describe('GitBranchSettings', () => { + beforeEach(() => { + mockBranches = [ + { + name: 'main', + }, + ] + }) + it('should match snapshot', () => { const { baseElement } = renderWithProviders( wrapWithReactHookForm() ) expect(baseElement).toMatchSnapshot() }) + + it('should display the selected branch when it is not part of returned branches', () => { + mockBranches = [ + { + name: 'develop', + }, + ] + + renderWithProviders( + wrapWithReactHookForm(, { + defaultValues: { + branch: 'main', + }, + }) + ) + + expect(screen.getByText('main')).toBeInTheDocument() + }) }) diff --git a/libs/domains/organizations/feature/src/lib/git-branch-settings/git-branch-settings.tsx b/libs/domains/organizations/feature/src/lib/git-branch-settings/git-branch-settings.tsx index 7deff50891e..034ff5d08b7 100644 --- a/libs/domains/organizations/feature/src/lib/git-branch-settings/git-branch-settings.tsx +++ b/libs/domains/organizations/feature/src/lib/git-branch-settings/git-branch-settings.tsx @@ -1,4 +1,5 @@ import { type GitProviderEnum } from 'qovery-typescript-axios' +import { useMemo } from 'react' import { Controller, useFormContext } from 'react-hook-form' import { InputSelect, InputText, LoaderSpinner } from '@qovery/shared/ui' import { useBranches } from '../hooks/use-branches/use-branches' @@ -40,6 +41,25 @@ export function GitBranchSettings({ enabled: !disabled, }) + const branchOptionsWithSelectedValue = useMemo(() => { + const branchOptions = branches.map((branch) => ({ + label: branch.name, + value: branch.name, + })) + + if (disabled || !watchFieldBranch || branchOptions.some((branch) => branch.value === watchFieldBranch)) { + return branchOptions + } + + return [ + ...branchOptions, + { + label: watchFieldBranch, + value: watchFieldBranch, + }, + ] + }, [branches, disabled, watchFieldBranch]) + if (isError) { return null } @@ -71,16 +91,14 @@ export function GitBranchSettings({ value: watchFieldBranch ?? '', }, ] - : branches.map((branch) => ({ - label: branch.name, - value: branch.name, - })) + : branchOptionsWithSelectedValue } onChange={field.onChange} value={field.value} error={error?.message} disabled={disabled} isSearchable + isCreatable /> )} /> From 07531f2e1e2f0d727d6139bcb0e3f027c122f7b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9mi=20Bonnet?= Date: Tue, 7 Jul 2026 14:02:36 +0200 Subject: [PATCH 15/15] fix(release): use merge commits for production promotion (#2802) --- .github/workflows/promote-production.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/promote-production.yml b/.github/workflows/promote-production.yml index c15ad26b30b..61a071cf39d 100644 --- a/.github/workflows/promote-production.yml +++ b/.github/workflows/promote-production.yml @@ -68,7 +68,7 @@ jobs: run: | pr_body="$(printf '%s\n\n%s' \ 'Opens the production promotion path from staging to main. Merging this PR triggers semantic-release, then the existing production deployment workflow.' \ - 'Do not squash this PR. Use rebase merge to preserve the original conventional commits for semantic-release.')" + 'Do not squash this PR. Use a merge commit to preserve the original conventional commits for semantic-release.')" pr_url="$(gh pr create \ --repo "$REPOSITORY" \ @@ -143,7 +143,7 @@ jobs: gh pr merge "$pr_url" \ --repo "$REPOSITORY" \ --auto \ - --rebase + --merge echo "Auto-merge enabled for ${pr_url}"