diff --git a/libs/domains/clusters/feature/src/index.ts b/libs/domains/clusters/feature/src/index.ts index 1935ba3ce7e..1f508e1390e 100644 --- a/libs/domains/clusters/feature/src/index.ts +++ b/libs/domains/clusters/feature/src/index.ts @@ -21,10 +21,12 @@ export * from './lib/hooks/use-edit-cluster-kubeconfig/use-edit-cluster-kubeconf export * from './lib/hooks/use-cluster-logs/use-cluster-logs' export * from './lib/hooks/use-cluster-status/use-cluster-status' export * from './lib/hooks/use-cluster-statuses/use-cluster-statuses' +export * from './lib/hooks/use-active-deployment-clusters/use-active-deployment-clusters' export * from './lib/hooks/use-cluster/use-cluster' export * from './lib/hooks/use-clusters/use-clusters' export * from './lib/hooks/use-cluster-advanced-settings/use-cluster-advanced-settings' export * from './lib/hooks/use-default-advanced-settings/use-default-advanced-settings' +export * from './lib/utils/cluster-install-tracking' export * from './lib/hooks/use-download-kubeconfig/use-download-kubeconfig' export * from './lib/hooks/use-create-cluster/use-create-cluster' export * from './lib/hooks/use-edit-cloud-provider-info/use-edit-cloud-provider-info' @@ -38,5 +40,9 @@ export * from './lib/hooks/use-upgrade-cluster/use-upgrade-cluster' export * from './lib/hooks/use-update-karpenter-private-fargate/use-update-karpenter-private-fargate' export * from './lib/hooks/use-cluster-running-status/use-cluster-running-status' export * from './lib/hooks/use-cluster-running-status-socket/use-cluster-running-status-socket' +export * from './lib/hooks/use-deployment-progress/use-deployment-progress' +export * from './lib/cluster-deployment-progress/cluster-deployment-progress-card/cluster-deployment-progress-card' +export * from './lib/cluster-deployment-progress/cluster-notification-permission-modal/cluster-notification-permission-modal' +export * from './lib/cluster-deployment-progress/cluster-notification-permission-modal/use-cluster-install-notifications' export * from './lib/gpu-resources-settings/gpu-resources-settings' export * from './lib/utils/has-gpu-instance' diff --git a/libs/domains/clusters/feature/src/lib/cluster-action-toolbar/cluster-action-toolbar.tsx b/libs/domains/clusters/feature/src/lib/cluster-action-toolbar/cluster-action-toolbar.tsx index 379416868d3..1c5ef3c5d47 100644 --- a/libs/domains/clusters/feature/src/lib/cluster-action-toolbar/cluster-action-toolbar.tsx +++ b/libs/domains/clusters/feature/src/lib/cluster-action-toolbar/cluster-action-toolbar.tsx @@ -28,6 +28,8 @@ import { useDownloadKubeconfig } from '../hooks/use-download-kubeconfig/use-down import { useStopCluster } from '../hooks/use-stop-cluster/use-stop-cluster' import { useUpgradeCluster } from '../hooks/use-upgrade-cluster/use-upgrade-cluster' +export const SHOW_SELF_MANAGED_GUIDE_KEY = 'show-self-managed-guide' + function MenuManageDeployment({ cluster, clusterStatus }: { cluster: Cluster; clusterStatus: ClusterStatus }) { const { openModalConfirmation } = useModalConfirmation() const { openModal } = useModal() @@ -263,7 +265,6 @@ export interface ClusterActionToolbarProps { export function ClusterActionToolbar({ cluster, clusterStatus }: ClusterActionToolbarProps) { const navigate = useNavigate() const { pathname } = useLocation() - const showSelfManagedGuideKey = 'show-self-managed-guide' const [searchParams, setSearchParams] = useSearchParams() const { openModal, closeModal } = useModal() const { data: runningStatus } = useClusterRunningStatus({ @@ -282,7 +283,7 @@ export function ClusterActionToolbar({ cluster, clusterStatus }: ClusterActionTo cluster={cluster} type={type} onClose={() => { - searchParams.delete(showSelfManagedGuideKey) + searchParams.delete(SHOW_SELF_MANAGED_GUIDE_KEY) setSearchParams(searchParams) closeModal() }} @@ -291,9 +292,9 @@ export function ClusterActionToolbar({ cluster, clusterStatus }: ClusterActionTo }) useEffect(() => { - const bool = searchParams.has(showSelfManagedGuideKey) && cluster.kubernetes === 'SELF_MANAGED' + const bool = searchParams.has(SHOW_SELF_MANAGED_GUIDE_KEY) && cluster.kubernetes === 'SELF_MANAGED' if (bool) { - searchParams.delete(showSelfManagedGuideKey) + searchParams.delete(SHOW_SELF_MANAGED_GUIDE_KEY) setSearchParams(searchParams) openInstallationGuideModal() } diff --git a/libs/domains/clusters/feature/src/lib/cluster-deployment-progress/cluster-deployment-progress-card/cluster-deployment-progress-card.spec.tsx b/libs/domains/clusters/feature/src/lib/cluster-deployment-progress/cluster-deployment-progress-card/cluster-deployment-progress-card.spec.tsx new file mode 100644 index 00000000000..4f76d76a8e2 --- /dev/null +++ b/libs/domains/clusters/feature/src/lib/cluster-deployment-progress/cluster-deployment-progress-card/cluster-deployment-progress-card.spec.tsx @@ -0,0 +1,138 @@ +import { type Cluster, type Project } from 'qovery-typescript-axios' +import { useProjects } from '@qovery/domains/projects/feature' +import { renderWithProviders, screen } from '@qovery/shared/util-tests' +import { useDeploymentProgress } from '../../hooks/use-deployment-progress/use-deployment-progress' +import ClusterDeploymentProgressCard, { + type ClusterDeploymentProgressCardProps, +} from './cluster-deployment-progress-card' + +jest.mock('@qovery/domains/projects/feature', () => ({ + ...jest.requireActual('@qovery/domains/projects/feature'), + useProjects: jest.fn(), +})) + +jest.mock('../../hooks/use-deployment-progress/use-deployment-progress', () => ({ + ...jest.requireActual('../../hooks/use-deployment-progress/use-deployment-progress'), + useDeploymentProgress: jest.fn(), +})) + +const mockUseProjects = useProjects as jest.MockedFunction +const mockUseDeploymentProgress = useDeploymentProgress as jest.MockedFunction + +describe('ClusterDeploymentProgressCard', () => { + const mockProject: Project = { + id: 'project-1', + name: 'Test Project', + organization: { id: 'org-1' }, + } as Project + + const mockCluster: Cluster = { + id: 'cluster-1', + name: 'Test Cluster', + organization: { id: 'org-1' }, + cloud_provider: 'AWS', + } as Cluster + + const props: ClusterDeploymentProgressCardProps = { + organizationId: 'org-1', + clusters: [mockCluster], + } + + beforeEach(() => { + mockUseProjects.mockReturnValue({ + data: [mockProject], + isLoading: false, + error: null, + } as ReturnType) + + mockUseDeploymentProgress.mockReturnValue({ + steps: [ + { label: 'Validating configuration', status: 'done' }, + { label: 'Providing infrastructure (on provider side)', status: 'current' }, + { label: 'Verifying provided infrastructure', status: 'pending' }, + { label: 'Installing Qovery stack', status: 'pending' }, + { label: 'Verifying kube deprecation API calls', status: 'pending' }, + ], + installationComplete: false, + highestStepIndex: 1, + progressValue: 0.4, + currentStepLabel: 'Providing infrastructure (on provider side)', + creationFailed: false, + state: 'installing', + justSucceeded: false, + justFailed: false, + }) + }) + + afterEach(() => { + jest.clearAllMocks() + }) + + it('should render successfully', () => { + const { baseElement } = renderWithProviders() + expect(baseElement).toBeTruthy() + }) + + it('should render cluster name when installing', () => { + renderWithProviders() + expect(screen.getByText('Test Cluster')).toBeInTheDocument() + }) + + it('should render current step label when installing', () => { + renderWithProviders() + expect(screen.getByText('Providing infrastructure (on provider side)')).toBeInTheDocument() + }) + + it('should render success state', () => { + mockUseDeploymentProgress.mockReturnValue({ + steps: [ + { label: 'Validating configuration', status: 'done' }, + { label: 'Providing infrastructure (on provider side)', status: 'done' }, + { label: 'Verifying provided infrastructure', status: 'done' }, + { label: 'Installing Qovery stack', status: 'done' }, + { label: 'Verifying kube deprecation API calls', status: 'done' }, + ], + installationComplete: true, + highestStepIndex: 4, + progressValue: 1, + currentStepLabel: 'Verifying kube deprecation API calls', + creationFailed: false, + state: 'succeeded', + justSucceeded: false, + justFailed: false, + }) + + renderWithProviders() + expect(screen.getByText(/Test Cluster.*created/)).toBeInTheDocument() + expect(screen.getByText('Start deploying')).toBeInTheDocument() + }) + + it('should render failed state', () => { + mockUseDeploymentProgress.mockReturnValue({ + steps: [ + { label: 'Validating configuration', status: 'done' }, + { label: 'Providing infrastructure (on provider side)', status: 'done' }, + { label: 'Verifying provided infrastructure', status: 'current' }, + { label: 'Installing Qovery stack', status: 'pending' }, + { label: 'Verifying kube deprecation API calls', status: 'pending' }, + ], + installationComplete: false, + highestStepIndex: 2, + progressValue: 0.6, + currentStepLabel: 'Verifying provided infrastructure', + creationFailed: true, + state: 'failed', + justSucceeded: false, + justFailed: false, + }) + + renderWithProviders() + expect(screen.getByText(/Test Cluster.*creation failed/)).toBeInTheDocument() + expect(screen.getByText('See logs')).toBeInTheDocument() + }) + + it('should return null when clusters array is empty', () => { + const { container } = renderWithProviders() + expect(container).toBeEmptyDOMElement() + }) +}) diff --git a/libs/domains/clusters/feature/src/lib/cluster-deployment-progress/cluster-deployment-progress-card/cluster-deployment-progress-card.tsx b/libs/domains/clusters/feature/src/lib/cluster-deployment-progress/cluster-deployment-progress-card/cluster-deployment-progress-card.tsx new file mode 100644 index 00000000000..83dd7ab5445 --- /dev/null +++ b/libs/domains/clusters/feature/src/lib/cluster-deployment-progress/cluster-deployment-progress-card/cluster-deployment-progress-card.tsx @@ -0,0 +1,176 @@ +import * as AccordionPrimitive from '@radix-ui/react-accordion' +import clsx from 'clsx' +import { type Cluster, type Project } from 'qovery-typescript-axios' +import { match } from 'ts-pattern' +import { useProjects } from '@qovery/domains/projects/feature' +import { INFRA_LOGS_URL, OVERVIEW_URL } from '@qovery/shared/routes' +import { AnimatedGradientText, Icon, Link } from '@qovery/shared/ui' +import { twMerge } from '@qovery/shared/util-js' +import { useDeploymentProgress } from '../../hooks/use-deployment-progress/use-deployment-progress' + +export interface ClusterDeploymentProgressCardProps { + organizationId: string + clusters: Cluster[] +} + +export function ClusterDeploymentProgressCard({ organizationId, clusters }: ClusterDeploymentProgressCardProps) { + const { data: projects = [] } = useProjects({ organizationId }) + + if (!clusters.length) return null + + return ( +
+ + {clusters.map((cluster) => ( + + ))} + +
+ ) +} + +function Item({ cluster, project }: { cluster: Cluster; project: Project }) { + const { steps, progressValue, currentStepLabel, state } = useDeploymentProgress({ + organizationId: cluster.organization.id, + clusterId: cluster.id, + clusterName: cluster.name, + cloudProvider: cluster.cloud_provider, + }) + + const { link, label } = match(state) + .with('failed', () => ({ + link: INFRA_LOGS_URL(cluster.organization.id, cluster.id), + label: 'See logs' as const, + })) + .with('succeeded', () => + project + ? { + link: OVERVIEW_URL(cluster.organization.id, project.id), + label: 'Start deploying' as const, + } + : { link: undefined, label: undefined } + ) + .otherwise(() => ({ link: undefined, label: undefined })) + + const isInstalling = state === 'installing' + const isFailed = state === 'failed' + const isSucceeded = state === 'succeeded' + const isDone = isFailed || isSucceeded + + const statusText = match(state) + .with('failed', () => 'creation failed') + .with('succeeded', () => 'created') + .otherwise(() => '') + + if (isInstalling && !isDone) { + return ( + + +
+ + {cluster.name} +
+
+ + + {currentStepLabel} + + + +
+
+ +
    + {steps.map(({ label, status }) => ( +
  • + {status === 'done' && } + {status === 'current' && ( + + )} + {status === 'pending' && } + + {label} + +
  • + ))} +
+
+
+ ) + } + + return ( +
+
+
+ {isSucceeded && } + {isFailed && } + {!isSucceeded && !isFailed && !isInstalling && ( + + )} + + {cluster.name} {statusText} + +
+
+ {link && label && ( + + {label} + + + )} + {!isSucceeded && !isFailed && !isInstalling && ( +

Deployment queued…

+ )} +
+
+
+ ) +} + +export default ClusterDeploymentProgressCard diff --git a/libs/domains/clusters/feature/src/lib/cluster-deployment-progress/cluster-notification-permission-modal/cluster-notification-permission-modal.spec.tsx b/libs/domains/clusters/feature/src/lib/cluster-deployment-progress/cluster-notification-permission-modal/cluster-notification-permission-modal.spec.tsx new file mode 100644 index 00000000000..4e627ae1b97 --- /dev/null +++ b/libs/domains/clusters/feature/src/lib/cluster-deployment-progress/cluster-notification-permission-modal/cluster-notification-permission-modal.spec.tsx @@ -0,0 +1,88 @@ +import { useNotificationPreferences } from '@qovery/shared/util-hooks' +import { renderWithProviders, screen } from '@qovery/shared/util-tests' +import ClusterNotificationPermissionModal, { + type ClusterNotificationPermissionModalProps, +} from './cluster-notification-permission-modal' + +jest.mock('@qovery/shared/util-hooks', () => ({ + ...jest.requireActual('@qovery/shared/util-hooks'), + useNotificationPreferences: jest.fn(), +})) + +const mockNavigate = jest.fn() +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useNavigate: () => mockNavigate, +})) + +const mockUseNotificationPreferences = useNotificationPreferences as jest.MockedFunction< + typeof useNotificationPreferences +> + +describe('ClusterNotificationPermissionModal', () => { + const props: ClusterNotificationPermissionModalProps = { + organizationId: 'org-1', + onClose: jest.fn(), + onComplete: jest.fn(), + } + + beforeEach(() => { + mockUseNotificationPreferences.mockReturnValue({ + notificationsEnabled: false, + setNotificationsEnabled: jest.fn(), + soundEnabled: false, + setSoundEnabled: jest.fn(), + requestPermission: jest.fn().mockResolvedValue(undefined), + isNotificationEnabled: jest.fn().mockReturnValue(false), + isSoundEnabled: jest.fn().mockReturnValue(false), + isBrowserNotificationSupported: true, + soundEnabledKey: 'cluster-sound-enabled', + notificationEnabledKey: 'cluster-notification-enabled', + }) + }) + + afterEach(() => { + jest.clearAllMocks() + }) + + it('should render successfully', () => { + const { baseElement } = renderWithProviders() + expect(baseElement).toBeTruthy() + }) + + it('should render the modal title and description', () => { + renderWithProviders() + + expect(screen.getByText('Get notified at completion')).toBeInTheDocument() + expect(screen.getByText('Choose how you want to be alerted when the installation completes.')).toBeInTheDocument() + }) + + it('should render notification and sound toggles', () => { + renderWithProviders() + + expect(screen.getByText('Browser notifications')).toBeInTheDocument() + expect(screen.getByText('Sound alert')).toBeInTheDocument() + }) + + it('should call onClose when Not now button is clicked', async () => { + const { userEvent } = renderWithProviders() + + const notNowButton = screen.getByText('Not now') + await userEvent.click(notNowButton) + + expect(props.onClose).toHaveBeenCalled() + }) + + it('should call onClose and onComplete when Confirm button is clicked', async () => { + const mockOnComplete = jest.fn().mockResolvedValue(undefined) + const { userEvent } = renderWithProviders( + + ) + + const confirmButton = screen.getByText('Confirm') + await userEvent.click(confirmButton) + + expect(props.onClose).toHaveBeenCalled() + expect(mockOnComplete).toHaveBeenCalled() + }) +}) diff --git a/libs/domains/clusters/feature/src/lib/cluster-deployment-progress/cluster-notification-permission-modal/cluster-notification-permission-modal.tsx b/libs/domains/clusters/feature/src/lib/cluster-deployment-progress/cluster-notification-permission-modal/cluster-notification-permission-modal.tsx new file mode 100644 index 00000000000..24fa1f1cbd3 --- /dev/null +++ b/libs/domains/clusters/feature/src/lib/cluster-deployment-progress/cluster-notification-permission-modal/cluster-notification-permission-modal.tsx @@ -0,0 +1,119 @@ +import { useCallback, useState } from 'react' +import { useNavigate } from 'react-router-dom' +import { CLUSTERS_GENERAL_URL, CLUSTERS_URL } from '@qovery/shared/routes' +import { Button, InputToggle } from '@qovery/shared/ui' +import { useLocalStorage, useNotificationPreferences } from '@qovery/shared/util-hooks' +import { SHOW_SELF_MANAGED_GUIDE_KEY } from '../../cluster-action-toolbar/cluster-action-toolbar' + +const NOTIFICATION_MODAL_SEEN_KEY = 'cluster-notification-permission-modal-v2-seen' + +export interface ClusterNotificationPermissionModalProps { + organizationId: string + onClose: () => void + onComplete: () => Promise + isSelfManaged?: boolean +} + +export function ClusterNotificationPermissionModal({ + organizationId, + onClose, + onComplete, + isSelfManaged = false, +}: ClusterNotificationPermissionModalProps) { + const navigate = useNavigate() + const { notificationsEnabled, setNotificationsEnabled, soundEnabled, setSoundEnabled, requestPermission } = + useNotificationPreferences({ prefix: 'cluster' }) + const [, setModalSeen] = useLocalStorage(NOTIFICATION_MODAL_SEEN_KEY, false) + const [isConfirming, setIsConfirming] = useState(false) + + const navigateToClusters = useCallback(() => { + if (isSelfManaged) { + navigate({ + pathname: CLUSTERS_URL(organizationId) + CLUSTERS_GENERAL_URL, + search: `?${SHOW_SELF_MANAGED_GUIDE_KEY}`, + }) + } else { + navigate(CLUSTERS_URL(organizationId)) + } + }, [navigate, organizationId, isSelfManaged]) + + const handleConfirm = async () => { + setModalSeen(true) + + if (!notificationsEnabled && !soundEnabled) { + onClose() + await onComplete() + return + } + + setIsConfirming(true) + try { + await requestPermission() + } catch (error) { + console.error(error) + } finally { + setIsConfirming(false) + onClose() + await onComplete() + navigateToClusters() + } + } + + return ( +
+
+

Get notified at completion

+

+ Choose how you want to be alerted when the installation completes. +

+
+ +
+ + +
+ +
+ + +
+
+ ) +} + +export default ClusterNotificationPermissionModal diff --git a/libs/domains/clusters/feature/src/lib/cluster-deployment-progress/cluster-notification-permission-modal/use-cluster-install-notifications.ts b/libs/domains/clusters/feature/src/lib/cluster-deployment-progress/cluster-notification-permission-modal/use-cluster-install-notifications.ts new file mode 100644 index 00000000000..522a193bdca --- /dev/null +++ b/libs/domains/clusters/feature/src/lib/cluster-deployment-progress/cluster-notification-permission-modal/use-cluster-install-notifications.ts @@ -0,0 +1,185 @@ +import { type Cluster, ClusterStateEnum, type ClusterStatus } from 'qovery-typescript-axios' +import { useEffect, useMemo, useRef, useState } from 'react' +import { useProjects } from '@qovery/domains/projects/feature' +import { CLUSTER_OVERVIEW_URL, CLUSTER_URL, INFRA_LOGS_URL, OVERVIEW_URL } from '@qovery/shared/routes' +import { isSoundEnabled, useNotificationPreferences } from '@qovery/shared/util-hooks' +import { + type LifecycleState, + getCachedDeploymentProgress, +} from '../../hooks/use-deployment-progress/use-deployment-progress' +import { clearTrackedClusterInstall, getTrackedClusterInstalls } from '../../utils/cluster-install-tracking' + +type ClusterStatusWithFlag = ClusterStatus + +const clusterCompletionSoundUrl = '/assets/sound/cluster_completion.mp3' + +const playCompletionSound = (soundEnabledKey: string) => { + if (!isSoundEnabled(soundEnabledKey) || typeof Audio === 'undefined') return + try { + const audio = new Audio(clusterCompletionSoundUrl) + audio.volume = 0.6 + void audio.play() + } catch (error) { + console.debug('Unable to play cluster completion sound', error) + } +} + +export function useClusterInstallNotifications({ + organizationId, + clusters = [], + clusterStatuses = [], +}: { + organizationId: string + clusters?: Cluster[] + clusterStatuses?: ClusterStatusWithFlag[] +}) { + const { isNotificationEnabled, soundEnabledKey } = useNotificationPreferences({ + prefix: 'cluster', + }) + const notifiedClustersRef = useRef>(new Set()) + const { data: projects = [] } = useProjects({ organizationId, enabled: !!organizationId }) + const [trackedInstalls, setTrackedInstalls] = useState(() => + typeof window === 'undefined' ? [] : getTrackedClusterInstalls() + ) + const trackedIdsRef = useRef>(new Set(trackedInstalls.map(({ id }) => id))) + const prevStateRef = useRef>(new Map()) + + const trackedNames = useMemo(() => { + const map = new Map() + trackedInstalls.forEach(({ id, name }) => map.set(id, name)) + return map + }, [trackedInstalls]) + + const deriveStateFromStatus = (status?: ClusterStateEnum, is_deployed?: boolean): LifecycleState => { + if ( + status === ClusterStateEnum.DEPLOYMENT_ERROR || + status === ClusterStateEnum.BUILD_ERROR || + status === ClusterStateEnum.DELETE_ERROR + ) { + return 'failed' + } + if (is_deployed || status === ClusterStateEnum.DEPLOYED || status === ClusterStateEnum.READY) return 'succeeded' + if ( + status === ClusterStateEnum.DEPLOYING || + status === ClusterStateEnum.DEPLOYMENT_QUEUED || + status === ClusterStateEnum.RESTARTING || + status === ClusterStateEnum.RESTART_QUEUED || + is_deployed === false + ) + return 'installing' + return 'idle' + } + + // Keep tracked IDs fresh in case they change after the hook mounted (e.g. new install starts) + useEffect(() => { + if (typeof window === 'undefined') return + + const syncTrackedIds = () => { + const installs = getTrackedClusterInstalls() + setTrackedInstalls(installs) + trackedIdsRef.current = new Set(installs.map(({ id }) => id)) + } + + syncTrackedIds() + const interval = setInterval(syncTrackedIds, 5_000) + window.addEventListener('storage', syncTrackedIds) + window.addEventListener('focus', syncTrackedIds) + + return () => { + clearInterval(interval) + window.removeEventListener('storage', syncTrackedIds) + window.removeEventListener('focus', syncTrackedIds) + } + }, []) + + useEffect(() => { + if (!isNotificationEnabled()) return + if (typeof window === 'undefined') return + if (trackedInstalls.length === 0) return + + trackedIdsRef.current = new Set(trackedInstalls.map(({ id }) => id)) + + clusterStatuses.forEach((status) => { + const clusterId = status?.cluster_id + if (!clusterId || !trackedIdsRef.current.has(clusterId)) return + + const cachedState = getCachedDeploymentProgress(clusterId)?.state + const derivedState = cachedState ?? deriveStateFromStatus(status?.status, status?.is_deployed) + const prevState = prevStateRef.current.get(clusterId) ?? 'idle' + + const transitionedToSuccess = prevState !== 'succeeded' && derivedState === 'succeeded' + const transitionedToFailure = prevState !== 'failed' && derivedState === 'failed' + + if (transitionedToFailure) { + notifiedClustersRef.current.add(clusterId) + clearTrackedClusterInstall(clusterId) + trackedIdsRef.current.delete(clusterId) + setTrackedInstalls((prev) => prev.filter((install) => install.id !== clusterId)) + + const clusterName = + trackedNames.get(clusterId) ?? clusters.find((cluster) => cluster.id === clusterId)?.name ?? 'Cluster' + + try { + const notification = new Notification(`${clusterName} installation failed`, { + body: `${clusterName} installation failed. Check cluster logs for details.`, + tag: clusterId, + }) + + notification.onclick = (event) => { + event.preventDefault() + window.focus() + window.location.href = INFRA_LOGS_URL(organizationId, clusterId) + } + } catch (error) { + console.error('Unable to show cluster installation failure notification', error) + } + } else if (transitionedToSuccess && !notifiedClustersRef.current.has(clusterId)) { + notifiedClustersRef.current.add(clusterId) + clearTrackedClusterInstall(clusterId) + trackedIdsRef.current.delete(clusterId) + setTrackedInstalls((prev) => prev.filter((install) => install.id !== clusterId)) + + const clusterName = + trackedNames.get(clusterId) ?? clusters.find((cluster) => cluster.id === clusterId)?.name ?? 'Cluster' + const firstProject = projects[0] + const body = firstProject?.name + ? `${clusterName} is ready. You can deploy your apps now in ${firstProject.name}.` + : `${clusterName} is ready. You can deploy your apps now.` + + try { + const notification = new Notification(`${clusterName} installed`, { + body, + tag: clusterId, + }) + + notification.onclick = (event) => { + event.preventDefault() + window.focus() + if (firstProject?.id) { + window.location.href = OVERVIEW_URL(organizationId, firstProject.id) + } else { + window.location.href = CLUSTER_URL(organizationId, clusterId) + CLUSTER_OVERVIEW_URL + } + } + } catch (error) { + console.error('Unable to show cluster installation notification', error) + } + + playCompletionSound(soundEnabledKey) + } + + prevStateRef.current.set(clusterId, derivedState) + }) + }, [ + clusterStatuses, + clusters, + organizationId, + projects, + trackedInstalls, + trackedNames, + soundEnabledKey, + isNotificationEnabled, + ]) +} + +export default useClusterInstallNotifications diff --git a/libs/domains/clusters/feature/src/lib/hooks/use-active-deployment-clusters/use-active-deployment-clusters.ts b/libs/domains/clusters/feature/src/lib/hooks/use-active-deployment-clusters/use-active-deployment-clusters.ts new file mode 100644 index 00000000000..f74af8f20d0 --- /dev/null +++ b/libs/domains/clusters/feature/src/lib/hooks/use-active-deployment-clusters/use-active-deployment-clusters.ts @@ -0,0 +1,102 @@ +import { ClusterStateEnum, type ClusterStatus } from 'qovery-typescript-axios' +import { useEffect, useMemo, useRef, useState } from 'react' + +interface UseActiveDeploymentClustersProps { + clusterStatuses?: ClusterStatus[] + trackedClusterIds?: string[] + gracePeriodMs?: number +} + +const isDeploying = ({ status, is_deployed }: { status?: ClusterStateEnum; is_deployed?: boolean }) => + (status === ClusterStateEnum.DEPLOYMENT_QUEUED || status === ClusterStateEnum.DEPLOYING) && + (is_deployed === false || is_deployed === undefined) + +const isTerminal = ({ status, is_deployed }: { status?: ClusterStateEnum; is_deployed?: boolean }) => + is_deployed === true || + status === ClusterStateEnum.DEPLOYED || + status === ClusterStateEnum.READY || + status === ClusterStateEnum.DEPLOYMENT_ERROR || + status === ClusterStateEnum.BUILD_ERROR || + status === ClusterStateEnum.DELETE_ERROR + +export function useActiveDeploymentClusters({ + clusterStatuses, + trackedClusterIds, + gracePeriodMs = 10000, +}: UseActiveDeploymentClustersProps) { + const [activeIds, setActiveIds] = useState([]) + const removalTimersRef = useRef>({}) + const dismissedIdsRef = useRef>(new Set()) + + const allowedIds = useMemo(() => new Set(trackedClusterIds), [trackedClusterIds]) + + useEffect(() => { + if (!clusterStatuses) return + + // Clear dismissal when a new deployment starts + clusterStatuses.forEach(({ cluster_id, status, is_deployed }) => { + if (!cluster_id) return + if (isDeploying({ status, is_deployed })) { + dismissedIdsRef.current.delete(cluster_id) + } + }) + + const idsToAdd = + clusterStatuses + .filter( + ({ status, is_deployed }) => isDeploying({ status, is_deployed }) || isTerminal({ status, is_deployed }) + ) + .map(({ cluster_id }) => cluster_id ?? '') + .filter((id): id is string => Boolean(id)) || [] + + if (idsToAdd.length) { + setActiveIds((prev) => { + const next = new Set(prev) + idsToAdd.forEach((id) => { + if (!dismissedIdsRef.current.has(id) && (!allowedIds.size || allowedIds.has(id))) { + next.add(id) + } + }) + return Array.from(next) + }) + } + }, [clusterStatuses, allowedIds]) + + useEffect(() => { + if (!clusterStatuses || activeIds.length === 0) return + + clusterStatuses.forEach(({ cluster_id, status, is_deployed }) => { + if (!cluster_id || !activeIds.includes(cluster_id)) return + const terminal = isTerminal({ status, is_deployed }) + const alreadyScheduled = removalTimersRef.current[cluster_id] + + if (terminal && !alreadyScheduled) { + const timer = window.setTimeout(() => { + setActiveIds((prev) => prev.filter((id) => id !== cluster_id)) + delete removalTimersRef.current[cluster_id] + dismissedIdsRef.current.add(cluster_id) + }, gracePeriodMs) + removalTimersRef.current[cluster_id] = timer + } + }) + + Object.entries(removalTimersRef.current).forEach(([clusterId, timer]) => { + if (!activeIds.includes(clusterId)) { + clearTimeout(timer) + delete removalTimersRef.current[clusterId] + } + }) + }, [clusterStatuses, activeIds, gracePeriodMs]) + + useEffect( + () => () => { + Object.values(removalTimersRef.current).forEach((timer) => clearTimeout(timer)) + removalTimersRef.current = {} + }, + [] + ) + + return { activeIds } +} + +export default useActiveDeploymentClusters diff --git a/libs/domains/clusters/feature/src/lib/hooks/use-cluster-logs/use-cluster-logs.ts b/libs/domains/clusters/feature/src/lib/hooks/use-cluster-logs/use-cluster-logs.ts index 896d8bfdd80..af8f7c384af 100644 --- a/libs/domains/clusters/feature/src/lib/hooks/use-cluster-logs/use-cluster-logs.ts +++ b/libs/domains/clusters/feature/src/lib/hooks/use-cluster-logs/use-cluster-logs.ts @@ -5,12 +5,19 @@ interface UseClusterLogsProps { organizationId: string clusterId: string refetchInterval?: number + refetchIntervalInBackground?: boolean } -export function useClusterLogs({ organizationId, clusterId, refetchInterval }: UseClusterLogsProps) { +export function useClusterLogs({ + organizationId, + clusterId, + refetchInterval, + refetchIntervalInBackground, +}: UseClusterLogsProps) { return useQuery({ ...queries.clusters.logs({ organizationId, clusterId }), refetchInterval, + refetchIntervalInBackground, }) } diff --git a/libs/domains/clusters/feature/src/lib/hooks/use-cluster-statuses/use-cluster-statuses.ts b/libs/domains/clusters/feature/src/lib/hooks/use-cluster-statuses/use-cluster-statuses.ts index 71041609027..a2e57d69024 100644 --- a/libs/domains/clusters/feature/src/lib/hooks/use-cluster-statuses/use-cluster-statuses.ts +++ b/libs/domains/clusters/feature/src/lib/hooks/use-cluster-statuses/use-cluster-statuses.ts @@ -5,13 +5,20 @@ interface UseClusterStatusesProps { organizationId: string refetchInterval?: number enabled?: boolean + refetchIntervalInBackground?: boolean } -export function useClusterStatuses({ organizationId, refetchInterval, enabled }: UseClusterStatusesProps) { +export function useClusterStatuses({ + organizationId, + refetchInterval, + enabled, + refetchIntervalInBackground, +}: UseClusterStatusesProps) { return useQuery({ ...queries.clusters.listStatuses({ organizationId }), refetchInterval, enabled, + refetchIntervalInBackground, }) } diff --git a/libs/domains/clusters/feature/src/lib/hooks/use-deployment-progress/use-deployment-progress.ts b/libs/domains/clusters/feature/src/lib/hooks/use-deployment-progress/use-deployment-progress.ts new file mode 100644 index 00000000000..2f0882ea9df --- /dev/null +++ b/libs/domains/clusters/feature/src/lib/hooks/use-deployment-progress/use-deployment-progress.ts @@ -0,0 +1,229 @@ +import { useEffect, useMemo, useState } from 'react' +import { useClusterLogs } from '../use-cluster-logs/use-cluster-logs' + +export type StepStatus = 'current' | 'pending' | 'done' +export type LifecycleState = 'idle' | 'installing' | 'succeeded' | 'failed' + +export const DEPLOYMENT_STEPS = [ + 'Validating configuration', + 'Providing infrastructure (on provider side)', + 'Verifying provided infrastructure', + 'Installing Qovery stack', + 'Verifying kube deprecation API calls', +] + +export interface UseDeploymentProgressProps { + organizationId: string + clusterId: string + clusterName?: string + cloudProvider?: string +} + +type ProgressCacheEntry = { + highestStepIndex: number + installationComplete: boolean + lastTimestamp?: number + creationFailed?: boolean + state?: LifecycleState + prevState?: LifecycleState +} + +// Module-level cache to survive remounts within the same session +const progressCache = new Map() + +export const getCachedDeploymentProgress = (clusterId: string): ProgressCacheEntry | undefined => + progressCache.get(clusterId) + +export function useDeploymentProgress({ + organizationId, + clusterId, + clusterName, + cloudProvider, +}: UseDeploymentProgressProps): { + steps: { label: string; status: StepStatus }[] + installationComplete: boolean + highestStepIndex: number + progressValue: number + currentStepLabel: string + creationFailed: boolean + state: LifecycleState + justSucceeded: boolean + justFailed: boolean +} { + const { data: clusterLogs } = useClusterLogs({ + organizationId, + clusterId, + refetchInterval: 3000, + refetchIntervalInBackground: true, + }) + + const providerCode = useMemo(() => { + switch (cloudProvider) { + case 'AWS': + return 'EKS' + case 'GCP': + return 'GKE' + case 'AZURE': + return 'AKS' + case 'SCALEWAY': + return 'ScwKapsule' + default: + return '' + } + }, [cloudProvider]) + + const [{ highestStepIndex, installationComplete }, setProgress] = useState<{ + highestStepIndex: number + installationComplete: boolean + }>(() => progressCache.get(clusterId) ?? { highestStepIndex: 0, installationComplete: false }) + const [creationFailed, setCreationFailed] = useState(false) + const [state, setState] = useState(() => progressCache.get(clusterId)?.state ?? 'idle') + const [justSucceeded, setJustSucceeded] = useState(false) + const [justFailed, setJustFailed] = useState(false) + + useEffect(() => { + if (!clusterLogs || clusterLogs.length === 0) return + let maxIndex = 0 + let isComplete = false + let isFailed = false + const latestTimestamp = clusterLogs[clusterLogs.length - 1]?.timestamp + + const cached = progressCache.get(clusterId) ?? { highestStepIndex: 0, installationComplete: false } + const startSteps = new Set([ + 'LoadConfiguration', + 'Create', + 'RetrieveClusterConfig', + 'RetrieveClusterResources', + 'ValidateSystemRequirements', + ]) + const sawStartStep = clusterLogs.some((log) => log.step && startSteps.has(log.step)) + + for (const log of clusterLogs) { + const userLogMessage = (log.error as { user_log_message?: string } | undefined)?.user_log_message ?? '' + const safeMessage = log.message?.safe_message ?? '' + const message = userLogMessage || safeMessage + const normalizedMessage = message.toLowerCase() + + if (normalizedMessage.includes('kubernetes cluster successfully created')) { + maxIndex = DEPLOYMENT_STEPS.length - 1 + isComplete = true + break + } + + const isCreateError = log.step === 'CreateError' || normalizedMessage.includes('createerror') + + if (isCreateError) { + isFailed = true + break + } + + const triggers: { index: number; match: (msg: string) => boolean }[] = [ + { + index: DEPLOYMENT_STEPS.length - 1, + match: (msg) => msg.includes('Check if cluster has calls to deprecated kubernetes API for version'), // last step + }, + { + index: 1, + match: () => { + if (!providerCode) return false + return normalizedMessage.includes(`deployment ${providerCode.toLowerCase()} cluster`) + }, // Providing infrastructure (on provider side) – name optional + }, + { + index: 2, + match: (msg) => + msg.includes('Kubernetes nodes have been successfully created') || + msg.includes('Checking if Karpenter nodegroup should be deployed...') || + msg.includes('Ensuring all groups nodes are in ready state from the Scaleway API') || + msg.includes( + 'Ensuring no failed nodegroups are present in the cluster, or delete them if at least one active nodegroup is present' + ), + }, + { + index: 3, + match: (msg) => msg.includes('Preparing Helm files on disk'), // installing Qovery stack + }, + ] + + for (const trigger of triggers) { + if (trigger.match(message)) { + maxIndex = Math.max(maxIndex, trigger.index) + } + } + + if (maxIndex === DEPLOYMENT_STEPS.length - 1) break + } + + const baseHighest = sawStartStep ? 0 : cached.highestStepIndex + const baseComplete = sawStartStep ? false : cached.installationComplete + + const nextHighest = Math.max(baseHighest, maxIndex) + const nextComplete = baseComplete || isComplete + + const mergedHighest = Math.max(highestStepIndex, nextHighest) + const mergedComplete = installationComplete || nextComplete + const nextFailed = (sawStartStep ? false : creationFailed) || isFailed + if (nextFailed && !creationFailed) { + console.log('[cluster] creation failed detected', { clusterId }) + } + const prevState = cached.state ?? state + const nextState: LifecycleState = nextFailed + ? 'failed' + : mergedComplete + ? 'succeeded' + : sawStartStep + ? 'installing' + : 'idle' + + setProgress({ highestStepIndex: mergedHighest, installationComplete: mergedComplete }) + setCreationFailed(nextFailed) + setState(nextState) + setJustSucceeded(prevState !== 'succeeded' && nextState === 'succeeded') + setJustFailed(prevState !== 'failed' && nextState === 'failed') + + progressCache.set(clusterId, { + highestStepIndex: Math.max(cached.highestStepIndex, mergedHighest), + installationComplete: cached.installationComplete || mergedComplete, + creationFailed: (sawStartStep ? false : cached.creationFailed) || nextFailed, + lastTimestamp: latestTimestamp ? new Date(latestTimestamp).getTime() : cached.lastTimestamp, + state: nextState, + }) + }, [clusterLogs, clusterId, clusterName, providerCode, creationFailed, highestStepIndex, installationComplete, state]) + + const steps = useMemo(() => { + return DEPLOYMENT_STEPS.map((label, index) => { + if (installationComplete) { + return { label, status: 'done' as StepStatus } + } + if (index < highestStepIndex) { + return { label, status: 'done' as StepStatus } + } + if (index === highestStepIndex) { + return { label, status: 'current' as StepStatus } + } + return { label, status: 'pending' as StepStatus } + }) + }, [highestStepIndex, installationComplete]) + + const progressValue = useMemo(() => { + const stepsCount = DEPLOYMENT_STEPS.length + const filledSteps = installationComplete ? stepsCount : Math.max(0, highestStepIndex) + return Math.min(filledSteps / stepsCount, 1) + }, [highestStepIndex, installationComplete]) + + const currentStepLabel = steps[Math.min(highestStepIndex, DEPLOYMENT_STEPS.length - 1)]?.label ?? DEPLOYMENT_STEPS[0] + + return { + steps, + installationComplete, + highestStepIndex, + progressValue, + currentStepLabel, + creationFailed, + state, + justSucceeded, + justFailed, + } +} + +export default useDeploymentProgress diff --git a/libs/domains/clusters/feature/src/lib/utils/cluster-install-tracking.ts b/libs/domains/clusters/feature/src/lib/utils/cluster-install-tracking.ts new file mode 100644 index 00000000000..7245054a5fb --- /dev/null +++ b/libs/domains/clusters/feature/src/lib/utils/cluster-install-tracking.ts @@ -0,0 +1,44 @@ +// LocalStorage helpers to remember cluster installs the user started, so UI/notifications can scope to them and survive reloads. +const TRACK_KEY = 'cluster-install-tracked-ids' +const MAX_AGE_MS = 1000 * 60 * 60 * 24 // 24h safety cleanup + +type TrackedInstall = { id: string; name?: string; createdAt: number } + +const readInstalls = (): TrackedInstall[] => { + if (typeof window === 'undefined') return [] + try { + const raw = localStorage.getItem(TRACK_KEY) + const parsed = raw ? (JSON.parse(raw) as TrackedInstall[]) : [] + const now = Date.now() + return parsed.filter((entry) => entry.id && now - entry.createdAt < MAX_AGE_MS) + } catch (error) { + console.error('Unable to read tracked cluster installs', error) + return [] + } +} + +const writeInstalls = (installs: TrackedInstall[]) => { + if (typeof window === 'undefined') return + try { + localStorage.setItem(TRACK_KEY, JSON.stringify(installs)) + } catch (error) { + console.error('Unable to persist tracked cluster installs', error) + } +} + +export const trackClusterInstall = (clusterId: string, clusterName?: string) => { + if (!clusterId) return + const existing = readInstalls().filter((entry) => entry.id !== clusterId) + existing.push({ id: clusterId, name: clusterName, createdAt: Date.now() }) + writeInstalls(existing) +} + +export const clearTrackedClusterInstall = (clusterId: string) => { + if (!clusterId) return + const next = readInstalls().filter((entry) => entry.id !== clusterId) + writeInstalls(next) +} + +export const getTrackedClusterInstallIds = (): string[] => readInstalls().map((entry) => entry.id) + +export const getTrackedClusterInstalls = (): TrackedInstall[] => readInstalls() diff --git a/libs/domains/clusters/feature/src/types/sound.d.ts b/libs/domains/clusters/feature/src/types/sound.d.ts new file mode 100644 index 00000000000..37e639e5a8a --- /dev/null +++ b/libs/domains/clusters/feature/src/types/sound.d.ts @@ -0,0 +1,4 @@ +declare module '*.mp3' { + const src: string + export default src +} diff --git a/libs/pages/cluster/src/lib/feature/page-overview-feature/page-overview-feature.spec.tsx b/libs/pages/cluster/src/lib/feature/page-overview-feature/page-overview-feature.spec.tsx index 61064ea9f8a..a30d064f71e 100644 --- a/libs/pages/cluster/src/lib/feature/page-overview-feature/page-overview-feature.spec.tsx +++ b/libs/pages/cluster/src/lib/feature/page-overview-feature/page-overview-feature.spec.tsx @@ -1,24 +1,44 @@ +import { ClusterStateEnum } from 'qovery-typescript-axios' import { useClusterMetrics } from '@qovery/domains/cluster-metrics/feature' -import { useCluster, useClusterRunningStatus } from '@qovery/domains/clusters/feature' +import { + useCluster, + useClusterLogs, + useClusterRunningStatus, + useClusterStatus, + useDeploymentProgress, +} from '@qovery/domains/clusters/feature' +import { useProjects } from '@qovery/domains/projects/feature' import { renderWithProviders, screen } from '@qovery/shared/util-tests' import PageOverviewFeature from './page-overview-feature' jest.mock('@qovery/domains/clusters/feature', () => ({ useCluster: jest.fn(), useClusterRunningStatus: jest.fn(), + useClusterLogs: jest.fn(), + useClusterStatus: jest.fn(), + useDeploymentProgress: jest.fn(), })) jest.mock('@qovery/domains/cluster-metrics/feature', () => ({ useClusterMetrics: jest.fn(), + ClusterCardNodeUsage: () =>
ClusterCardNodeUsage
, + ClusterCardResources: () =>
ClusterCardResources
, + ClusterCardSetup: () =>
ClusterCardSetup
, + ClusterTableNode: () =>
ClusterTableNode
, + ClusterTableNodepool: () =>
ClusterTableNodepool
, +})) + +jest.mock('@qovery/domains/projects/feature', () => ({ + useProjects: jest.fn(), })) describe('PageOverviewFeature', () => { beforeEach(() => { jest.clearAllMocks() - ;(useClusterRunningStatus as jest.Mock).mockReturnValue({ - data: 'NotFound', + jest.mocked(useClusterRunningStatus).mockReturnValue({ + data: { computed_status: { global_status: 'RUNNING' } }, }) - ;(useCluster as jest.Mock).mockReturnValue({ + jest.mocked(useCluster).mockReturnValue({ data: { id: 'cluster-123', name: 'test-cluster', @@ -28,12 +48,32 @@ describe('PageOverviewFeature', () => { min_running_nodes: 1, }, }) - ;(useClusterMetrics as jest.Mock).mockReturnValue({ + jest.mocked(useClusterMetrics).mockReturnValue({ data: { node_pools: [], nodes: [], }, }) + jest.mocked(useClusterLogs).mockReturnValue({ + data: [], + }) + jest.mocked(useClusterStatus).mockReturnValue({ + data: { is_deployed: true, status: ClusterStateEnum.DEPLOYED }, + }) + jest.mocked(useDeploymentProgress).mockReturnValue({ + steps: [{ label: 'Validating config', status: 'current' }], + installationComplete: false, + highestStepIndex: 0, + progressValue: 0.1, + currentStepLabel: 'Validating config', + creationFailed: false, + state: 'installing', + justSucceeded: false, + justFailed: false, + }) + jest.mocked(useProjects).mockReturnValue({ + data: [], + }) }) it('should render successfully', () => { @@ -42,7 +82,32 @@ describe('PageOverviewFeature', () => { }) it('should render placeholder when running status is unavailable', () => { + jest.mocked(useClusterRunningStatus).mockReturnValue({ + data: 'NotFound', + }) renderWithProviders() expect(screen.getByText('No metrics available because the running status is unavailable.')).toBeInTheDocument() }) + + it('should display deployment ongoing card when cluster creation is in progress', () => { + jest.mocked(useClusterStatus).mockReturnValue({ + data: { is_deployed: false, status: ClusterStateEnum.DEPLOYING }, + }) + jest.mocked(useCluster).mockReturnValue({ + data: { + id: 'cluster-123', + name: 'test-cluster', + cloud_provider: 'AWS', + instance_type: 'MANAGED', + max_running_nodes: 10, + min_running_nodes: 1, + }, + }) + + renderWithProviders() + + expect(screen.getByText('Deployment ongoing')).toBeInTheDocument() + expect(screen.getByRole('link', { name: /cluster logs/i })).toBeInTheDocument() + expect(screen.getByText('Validating config')).toBeInTheDocument() + }) }) diff --git a/libs/pages/cluster/src/lib/feature/page-overview-feature/page-overview-feature.tsx b/libs/pages/cluster/src/lib/feature/page-overview-feature/page-overview-feature.tsx index 53011bc7f98..cd79da0d388 100644 --- a/libs/pages/cluster/src/lib/feature/page-overview-feature/page-overview-feature.tsx +++ b/libs/pages/cluster/src/lib/feature/page-overview-feature/page-overview-feature.tsx @@ -1,3 +1,6 @@ +import clsx from 'clsx' +import { ClusterStateEnum } from 'qovery-typescript-axios' +import { useEffect, useRef, useState } from 'react' import { useParams } from 'react-router-dom' import { ClusterCardNodeUsage, @@ -7,9 +10,11 @@ import { ClusterTableNodepool, useClusterMetrics, } from '@qovery/domains/cluster-metrics/feature' -import { useCluster, useClusterRunningStatus } from '@qovery/domains/clusters/feature' +import { useCluster, useClusterRunningStatus, useClusterStatus } from '@qovery/domains/clusters/feature' +import { displayClusterDeploymentBanner } from '@qovery/pages/layout' import { Icon, Tooltip } from '@qovery/shared/ui' import { useDocumentTitle } from '@qovery/shared/util-hooks' +import { DeploymentOngoingCard } from '../../ui/deployment-ongoing-card/deployment-ongoing-card' import { TableSkeleton } from './table-skeleton' function TableLegend() { @@ -34,10 +39,92 @@ export function PageOverviewFeature() { const { data: cluster, isLoading: isClusterLoading } = useCluster({ organizationId, clusterId }) const { data: runningStatus } = useClusterRunningStatus({ organizationId, clusterId }) const { data: clusterMetrics } = useClusterMetrics({ organizationId, clusterId }) + const { data: clusterStatus } = useClusterStatus({ organizationId, clusterId, refetchInterval: 5000 }) + const [showDeploymentCard, setShowDeploymentCard] = useState(false) + const [renderDeploymentCard, setRenderDeploymentCard] = useState(false) + const [isDeploymentCardFading, setIsDeploymentCardFading] = useState(false) + const hideTimeoutRef = useRef | null>(null) + const fadeTimeoutRef = useRef | null>(null) + const hasSeenDeploymentInProgress = useRef(false) const isLoading = isClusterLoading || !runningStatus || !clusterMetrics - const isKarpenter = cluster?.features?.find((feature) => feature.id === 'KARPENTER') + const isDeploymentInProgress = + clusterStatus && + displayClusterDeploymentBanner(clusterStatus?.status ?? cluster?.status) && + !clusterStatus?.is_deployed + const failureStatuses: ClusterStateEnum[] = [ + ClusterStateEnum.DEPLOYMENT_ERROR, + ClusterStateEnum.BUILD_ERROR, + ClusterStateEnum.DELETE_ERROR, + ] + const isDeploymentFailed = clusterStatus ? failureStatuses.includes(clusterStatus.status as ClusterStateEnum) : false + + useEffect(() => { + if (isDeploymentInProgress) { + hasSeenDeploymentInProgress.current = true + if (hideTimeoutRef.current) { + clearTimeout(hideTimeoutRef.current) + hideTimeoutRef.current = null + } + setShowDeploymentCard(true) + return + } + + if (hasSeenDeploymentInProgress.current && (clusterStatus?.is_deployed || isDeploymentFailed)) { + if (!hideTimeoutRef.current) { + hideTimeoutRef.current = setTimeout(() => { + setShowDeploymentCard(false) + hideTimeoutRef.current = null + hasSeenDeploymentInProgress.current = false + }, 10000) + } + setShowDeploymentCard(true) + return + } + + if (hideTimeoutRef.current) { + clearTimeout(hideTimeoutRef.current) + hideTimeoutRef.current = null + } + hasSeenDeploymentInProgress.current = false + setShowDeploymentCard(false) + }, [clusterStatus?.is_deployed, isDeploymentInProgress]) + + useEffect(() => { + if (showDeploymentCard) { + if (fadeTimeoutRef.current) { + clearTimeout(fadeTimeoutRef.current) + fadeTimeoutRef.current = null + } + setIsDeploymentCardFading(false) + setRenderDeploymentCard(true) + return + } + + if (renderDeploymentCard && !fadeTimeoutRef.current) { + setIsDeploymentCardFading(true) + fadeTimeoutRef.current = setTimeout(() => { + setRenderDeploymentCard(false) + setIsDeploymentCardFading(false) + fadeTimeoutRef.current = null + }, 300) + } + }, [renderDeploymentCard, showDeploymentCard]) + + useEffect( + () => () => { + if (hideTimeoutRef.current) { + clearTimeout(hideTimeoutRef.current) + hideTimeoutRef.current = null + } + if (fadeTimeoutRef.current) { + clearTimeout(fadeTimeoutRef.current) + fadeTimeoutRef.current = null + } + }, + [] + ) if (typeof runningStatus === 'string') { return ( @@ -52,6 +139,16 @@ export function PageOverviewFeature() { return (
+ {renderDeploymentCard && ( +
+ +
+ )}
diff --git a/libs/pages/cluster/src/lib/ui/deployment-ongoing-card/deployment-ongoing-card.tsx b/libs/pages/cluster/src/lib/ui/deployment-ongoing-card/deployment-ongoing-card.tsx new file mode 100644 index 00000000000..e19c0d48ed0 --- /dev/null +++ b/libs/pages/cluster/src/lib/ui/deployment-ongoing-card/deployment-ongoing-card.tsx @@ -0,0 +1,123 @@ +import { useLocation } from 'react-router-dom' +import { useDeploymentProgress } from '@qovery/domains/clusters/feature' +import { useProjects } from '@qovery/domains/projects/feature' +import { INFRA_LOGS_URL } from '@qovery/shared/routes' +import { OVERVIEW_URL } from '@qovery/shared/routes' +import { Icon, Link } from '@qovery/shared/ui' + +export interface DeploymentOngoingCardProps { + organizationId: string + clusterId: string + clusterName?: string + cloudProvider?: string +} + +export function DeploymentOngoingCard({ + organizationId, + clusterId, + clusterName, + cloudProvider, +}: DeploymentOngoingCardProps) { + const { pathname } = useLocation() + const { data: projects = [] } = useProjects({ organizationId, enabled: !!organizationId }) + const { steps, progressValue, state } = useDeploymentProgress({ + organizationId, + clusterId, + clusterName, + cloudProvider, + }) + + const isInstalling = state === 'installing' + const isFailed = state === 'failed' + const isSucceeded = state === 'succeeded' + + const deploymentLink = + isFailed || !projects[0] + ? INFRA_LOGS_URL(organizationId, clusterId) + : isSucceeded + ? OVERVIEW_URL(organizationId, projects[0].id) + : INFRA_LOGS_URL(organizationId, clusterId) + const deploymentLinkLabel = isFailed ? 'See logs' : isSucceeded && projects[0] ? 'Start deploying' : 'Cluster logs' + + return ( +
+
+
+ {isFailed ? ( + <> + + Cluster install failed + + ) : isSucceeded ? ( + <> + + Cluster installed! + + ) : ( + <> + + Deployment ongoing + + )} +
+
+ + {deploymentLinkLabel} + + +
+
+ {isInstalling && ( +
+ {steps.map(({ label, status }: { label: string; status: 'current' | 'pending' | 'done' }, index: number) => ( +
+ {status === 'done' && ( + + )} + {status === 'current' && ( + + )} + {status === 'pending' && ( + + )} + + {label} + + {index < steps.length - 1 && ( + + ⸺ + + )} +
+ ))} +
+ )} +
+ ) +} + +export default DeploymentOngoingCard diff --git a/libs/pages/clusters/src/lib/feature/page-clusters-create-feature/step-summary-feature/step-summary-feature.tsx b/libs/pages/clusters/src/lib/feature/page-clusters-create-feature/step-summary-feature/step-summary-feature.tsx index f38b2f34702..69541b9b9e2 100644 --- a/libs/pages/clusters/src/lib/feature/page-clusters-create-feature/step-summary-feature/step-summary-feature.tsx +++ b/libs/pages/clusters/src/lib/feature/page-clusters-create-feature/step-summary-feature/step-summary-feature.tsx @@ -9,16 +9,16 @@ import { useNavigate, useParams } from 'react-router-dom' import { match } from 'ts-pattern' import { SCW_CONTROL_PLANE_FEATURE_ID, useCloudProviderInstanceTypes } from '@qovery/domains/cloud-providers/feature' import { useCreateCluster, useDeployCluster, useEditCloudProviderInfo } from '@qovery/domains/clusters/feature' +import { trackClusterInstall } from '@qovery/domains/clusters/feature' +import { ClusterNotificationPermissionModal } from '@qovery/domains/clusters/feature' import { CLUSTERS_CREATION_EKS_URL, CLUSTERS_CREATION_FEATURES_URL, CLUSTERS_CREATION_GENERAL_URL, CLUSTERS_CREATION_KUBECONFIG_URL, CLUSTERS_CREATION_RESOURCES_URL, - CLUSTERS_GENERAL_URL, - CLUSTERS_URL, } from '@qovery/shared/routes' -import { FunnelFlowBody } from '@qovery/shared/ui' +import { FunnelFlowBody, useModal } from '@qovery/shared/ui' import { useDocumentTitle } from '@qovery/shared/util-hooks' import StepSummary from '../../../ui/page-clusters-create/step-summary/step-summary' import { steps, useClusterContainerCreateContext } from '../page-clusters-create-feature' @@ -85,6 +85,24 @@ export function StepSummaryFeature() { navigate(creationFlowUrl + CLUSTERS_CREATION_EKS_URL) } + const { openModal, closeModal } = useModal() + + const showNotificationPermissionModal = useCallback( + (isSelfManaged = false, onComplete: () => Promise) => { + openModal({ + content: ( + + ), + }) + }, + [openModal, closeModal, organizationId] + ) + const onBack = () => { if (generalData?.installation_type === 'SELF_MANAGED') { goToKubeconfig() @@ -116,27 +134,26 @@ export function StepSummaryFeature() { if (generalData.installation_type === 'SELF_MANAGED' && kubeconfigData) { try { - const cluster = await createCluster({ - organizationId, - clusterRequest: { - name: generalData.name, - description: generalData.description, - region: generalData.region, - cloud_provider: generalData.cloud_provider, - kubernetes: 'SELF_MANAGED', - production: generalData.production, - features: [], - cloud_provider_credentials, - }, - }) - await editCloudProviderInfo({ - organizationId, - clusterId: cluster.id, - cloudProviderInfoRequest: cloud_provider_credentials, - }) - navigate({ - pathname: creationFlowUrl + CLUSTERS_GENERAL_URL, - search: '?show-self-managed-guide', + showNotificationPermissionModal(true, async () => { + const cluster = await createCluster({ + organizationId, + clusterRequest: { + name: generalData.name, + description: generalData.description, + region: generalData.region, + cloud_provider: generalData.cloud_provider, + kubernetes: 'SELF_MANAGED', + production: generalData.production, + features: [], + cloud_provider_credentials, + }, + }) + trackClusterInstall(cluster.id, cluster.name) + await editCloudProviderInfo({ + organizationId, + clusterId: cluster.id, + cloudProviderInfoRequest: cloud_provider_credentials, + }) }) } catch (e) { console.error(e) @@ -147,22 +164,24 @@ export function StepSummaryFeature() { // EKS if (generalData.installation_type === 'PARTIALLY_MANAGED') { try { - await createCluster({ - organizationId, - clusterRequest: { - name: generalData.name, - description: generalData.description, - region: generalData.region, - cloud_provider: generalData.cloud_provider, - kubernetes: 'PARTIALLY_MANAGED', - production: generalData.production, - features: [], - cloud_provider_credentials, - infrastructure_charts_parameters: resourcesData?.infrastructure_charts_parameters, - }, + showNotificationPermissionModal(false, async () => { + const cluster = await createCluster({ + organizationId, + clusterRequest: { + name: generalData.name, + description: generalData.description, + region: generalData.region, + cloud_provider: generalData.cloud_provider, + kubernetes: 'PARTIALLY_MANAGED', + production: generalData.production, + features: [], + cloud_provider_credentials, + infrastructure_charts_parameters: resourcesData?.infrastructure_charts_parameters, + }, + }) + // TODO REMI: Check + trackClusterInstall(cluster.id, cluster.name) }) - - navigate(CLUSTERS_URL(organizationId)) } catch (e) { console.error(e) } @@ -348,20 +367,23 @@ export function StepSummaryFeature() { }) try { - const cluster = await createCluster({ - organizationId, - clusterRequest, - }) - await editCloudProviderInfo({ - organizationId, - clusterId: cluster.id, - cloudProviderInfoRequest: cloud_provider_credentials, - }) + showNotificationPermissionModal(false, async () => { + const cluster = await createCluster({ + organizationId, + clusterRequest, + }) + // TODO REMI: Check + trackClusterInstall(cluster.id, cluster.name) + await editCloudProviderInfo({ + organizationId, + clusterId: cluster.id, + cloudProviderInfoRequest: cloud_provider_credentials, + }) - if (withDeploy) { - await deployCluster({ clusterId: cluster.id, organizationId }) - } - navigate(CLUSTERS_URL(organizationId)) + if (withDeploy) { + await deployCluster({ clusterId: cluster.id, organizationId }) + } + }) } catch (e) { console.error(e) } diff --git a/libs/pages/layout/src/lib/ui/layout-page/layout-page.spec.tsx b/libs/pages/layout/src/lib/ui/layout-page/layout-page.spec.tsx index 2aa489dfdb2..30956dc515a 100644 --- a/libs/pages/layout/src/lib/ui/layout-page/layout-page.spec.tsx +++ b/libs/pages/layout/src/lib/ui/layout-page/layout-page.spec.tsx @@ -3,17 +3,22 @@ import { IntercomProvider } from 'react-use-intercom' import { renderWithProviders, screen } from '@qovery/shared/util-tests' import LayoutPage, { type LayoutPageProps } from './layout-page' +// Override global mocks with test-specific values jest.mock('@qovery/domains/clusters/feature', () => { return { ...jest.requireActual('@qovery/domains/clusters/feature'), - useClusterStatuses: () => ({ + useClusterStatuses: jest.fn(() => ({ data: [ { cluster_id: '0000-0000-0000-0000', status: 'INVALID_CREDENTIALS', }, ], - }), + isLoading: false, + isFetching: false, + refetch: jest.fn(), + })), + useClusterInstallNotifications: jest.fn(() => undefined), } }) @@ -29,9 +34,14 @@ describe('LayoutPage', () => { topBar: false, } + afterEach(() => { + jest.clearAllMocks() + }) + it('should render successfully', () => { - const { baseElement } = renderWithProviders(renderComponent({ ...props })) + const { baseElement, unmount } = renderWithProviders(renderComponent({ ...props })) expect(baseElement).toBeTruthy() + unmount() }) it('should have cluster deployment error banner', () => { @@ -45,7 +55,8 @@ describe('LayoutPage', () => { }, ] - renderWithProviders(renderComponent({ ...props })) + const { unmount } = renderWithProviders(renderComponent({ ...props })) screen.getByText('Check the credentials configuration') + unmount() }) }) diff --git a/libs/pages/layout/src/lib/ui/layout-page/layout-page.tsx b/libs/pages/layout/src/lib/ui/layout-page/layout-page.tsx index 810fe491b80..142db7f3554 100644 --- a/libs/pages/layout/src/lib/ui/layout-page/layout-page.tsx +++ b/libs/pages/layout/src/lib/ui/layout-page/layout-page.tsx @@ -1,9 +1,15 @@ import { useFeatureFlagVariantKey } from 'posthog-js/react' import { type Cluster, ClusterStateEnum, type Organization } from 'qovery-typescript-axios' -import { type PropsWithChildren, useMemo } from 'react' +import { type PropsWithChildren, useEffect, useMemo, useState } from 'react' import { useLocation, useNavigate, useParams } from 'react-router-dom' import { match } from 'ts-pattern' -import { useClusterStatuses } from '@qovery/domains/clusters/feature' +import { + ClusterDeploymentProgressCard, + useActiveDeploymentClusters, + useClusterInstallNotifications, + useClusterStatuses, +} from '@qovery/domains/clusters/feature' +import { getTrackedClusterInstallIds } from '@qovery/domains/clusters/feature' import { InvoiceBanner, useOrganization } from '@qovery/domains/organizations/feature' import { AssistantTrigger } from '@qovery/shared/assistant/feature' import { DevopsCopilotButton, DevopsCopilotTrigger } from '@qovery/shared/devops-copilot/feature' @@ -52,7 +58,18 @@ export function LayoutPage(props: PropsWithChildren) { const { organizationId = '' } = useParams() const { pathname } = useLocation() const navigate = useNavigate() - const { data: clusterStatuses } = useClusterStatuses({ organizationId, enabled: !!organizationId }) + const [shouldPollClusterStatuses, setShouldPollClusterStatuses] = useState(false) + const [trackedClusterIds, setTrackedClusterIds] = useState(getTrackedClusterInstallIds()) + const { data: clusterStatuses } = useClusterStatuses({ + organizationId, + enabled: !!organizationId, + refetchInterval: shouldPollClusterStatuses ? 5000 : undefined, + refetchIntervalInBackground: true, + }) + const { activeIds: activeDeploymentClusterIds } = useActiveDeploymentClusters({ + clusterStatuses, + trackedClusterIds, + }) const { data: organization } = useOrganization({ organizationId }) const { roles, isQoveryAdminUser } = useUserRole() const isFeatureFlag = useFeatureFlagVariantKey('devops-copilot') @@ -70,6 +87,11 @@ export function LayoutPage(props: PropsWithChildren) { const clusterBanner = !matchLogInfraRoute && clusters && displayClusterDeploymentBanner(firstClusterStatus?.status) && !clusterIsDeployed + const deployingClusters = + clusters?.filter(({ id }) => activeDeploymentClusterIds.includes(id) && trackedClusterIds.includes(id)) || [] + const isOnClusterPage = pathname.includes('/cluster/') + const showFloatingDeploymentCard = deployingClusters.length > 0 && !isOnClusterPage + const invalidCluster = clusters?.find( ({ id }) => clusterStatuses?.find(({ status }) => status === ClusterStateEnum.INVALID_CREDENTIALS)?.cluster_id === id @@ -105,6 +127,22 @@ export function LayoutPage(props: PropsWithChildren) { return false }, [roles, organizationId, isQoveryAdminUser]) + useEffect(() => { + const hasDeployingCluster = + clusterStatuses?.some( + ({ status, is_deployed }) => + displayClusterDeploymentBanner(status) && (is_deployed === false || is_deployed === undefined) + ) ?? false + setShouldPollClusterStatuses(hasDeployingCluster) + setTrackedClusterIds(getTrackedClusterInstallIds()) + }, [clusterStatuses]) + + useClusterInstallNotifications({ + organizationId, + clusters: clusters ?? [], + clusterStatuses, + }) + return ( <> {displayQoveryAdminBanner && ( @@ -152,16 +190,6 @@ export function LayoutPage(props: PropsWithChildren) { invalid. )} - {clusterBanner && ( - navigate(INFRA_LOGS_URL(organizationId, firstCluster?.id))} - buttonLabel="See logs" - > - Installation of the cluster {firstCluster?.name} is - ongoing, you can follow it from logs - - )} {topBar && ( @@ -173,6 +201,9 @@ export function LayoutPage(props: PropsWithChildren) { )}
+ {showFloatingDeploymentCard && ( + + )} ) diff --git a/libs/shared/ui/src/lib/assets/sound/cluster_completion.mp3 b/libs/shared/ui/src/lib/assets/sound/cluster_completion.mp3 new file mode 100644 index 00000000000..c4e20c7480e Binary files /dev/null and b/libs/shared/ui/src/lib/assets/sound/cluster_completion.mp3 differ diff --git a/libs/shared/util-hooks/src/index.ts b/libs/shared/util-hooks/src/index.ts index 6ca1e99c5c2..d6e8df0a402 100644 --- a/libs/shared/util-hooks/src/index.ts +++ b/libs/shared/util-hooks/src/index.ts @@ -14,3 +14,4 @@ export { useDocumentTitle, useClickAway, useCopyToClipboard, useDebounce, useLoc export * from './lib/use-format-hotkeys/use-format-hotkeys' export * from './lib/use-support-chat/use-support-chat' export * from './lib/use-pod-color/use-pod-color' +export * from './lib/use-notification-preferences/use-notification-preferences' diff --git a/libs/shared/util-hooks/src/lib/use-notification-preferences/use-notification-preferences.spec.ts b/libs/shared/util-hooks/src/lib/use-notification-preferences/use-notification-preferences.spec.ts new file mode 100644 index 00000000000..389fbd9cd21 --- /dev/null +++ b/libs/shared/util-hooks/src/lib/use-notification-preferences/use-notification-preferences.spec.ts @@ -0,0 +1,116 @@ +import { act, renderHook } from '@qovery/shared/util-tests' +import { isNotificationEnabled, isSoundEnabled, useNotificationPreferences } from './use-notification-preferences' + +describe('useNotificationPreferences', () => { + beforeEach(() => { + localStorage.clear() + Object.defineProperty(window, 'Notification', { + writable: true, + value: { + permission: 'default', + requestPermission: jest.fn().mockResolvedValue('granted'), + }, + }) + }) + + afterEach(() => { + localStorage.clear() + }) + + it('should initialize with default values', () => { + const { result } = renderHook(() => useNotificationPreferences({ prefix: 'test' })) + + expect(result.current.notificationsEnabled).toBe(false) + expect(result.current.soundEnabled).toBe(false) + }) + + it('should not request notification permission when notificationsEnabled is false', async () => { + const { result } = renderHook(() => useNotificationPreferences({ prefix: 'test' })) + + await act(async () => { + await result.current.requestPermission() + }) + + expect(window.Notification.requestPermission).not.toHaveBeenCalled() + }) + + it('should check browser notification support', () => { + const { result } = renderHook(() => useNotificationPreferences({ prefix: 'test' })) + + expect(result.current.isBrowserNotificationSupported).toBe(true) + }) +}) + +describe('isNotificationEnabled', () => { + beforeEach(() => { + localStorage.clear() + Object.defineProperty(window, 'Notification', { + writable: true, + value: { + permission: 'granted', + }, + }) + }) + + afterEach(() => { + localStorage.clear() + }) + + it('should return false when browser does not support notifications', () => { + Object.defineProperty(window, 'Notification', { + writable: true, + value: undefined, + }) + + expect(isNotificationEnabled('test-notification-enabled')).toBe(false) + }) + + it('should return false when permission is not granted', () => { + Object.defineProperty(window, 'Notification', { + writable: true, + value: { + permission: 'denied', + }, + }) + + expect(isNotificationEnabled('test-notification-enabled')).toBe(false) + }) + + it('should return false when localStorage value is false', () => { + localStorage.setItem('test-notification-enabled', 'false') + + expect(isNotificationEnabled('test-notification-enabled')).toBe(false) + }) + + it('should return true when all conditions are met', () => { + localStorage.setItem('test-notification-enabled', 'true') + + expect(isNotificationEnabled('test-notification-enabled')).toBe(true) + }) +}) + +describe('isSoundEnabled', () => { + beforeEach(() => { + localStorage.clear() + }) + + afterEach(() => { + localStorage.clear() + }) + + it('should return false when localStorage value is not set', () => { + expect(isSoundEnabled('test-sound-enabled')).toBe(false) + }) + + it('should return false when localStorage value is false', () => { + localStorage.setItem('test-sound-enabled', 'false') + + expect(isSoundEnabled('test-sound-enabled')).toBe(false) + }) + + it('should return true when localStorage value is true', () => { + localStorage.setItem('test-sound-enabled', 'true') + + expect(isSoundEnabled('test-sound-enabled')).toBe(true) + }) +}) diff --git a/libs/shared/util-hooks/src/lib/use-notification-preferences/use-notification-preferences.ts b/libs/shared/util-hooks/src/lib/use-notification-preferences/use-notification-preferences.ts new file mode 100644 index 00000000000..8ffa155d177 --- /dev/null +++ b/libs/shared/util-hooks/src/lib/use-notification-preferences/use-notification-preferences.ts @@ -0,0 +1,72 @@ +import { useCallback } from 'react' +import { useLocalStorage } from '../../index' + +const isBrowserNotificationSupported = (): boolean => typeof window !== 'undefined' && 'Notification' in window + +const getStoredBoolean = (key: string): boolean => { + if (typeof window === 'undefined') return false + return localStorage.getItem(key) === 'true' +} + +const requestNotificationPermission = async (): Promise => { + if (!isBrowserNotificationSupported()) return + if (Notification.permission === 'default') { + try { + await Notification.requestPermission() + } catch (error) { + console.error(error) + } + } +} + +const requestSoundPermission = async (): Promise => { + return +} + +export const isNotificationEnabled = (notificationEnabledKey: string): boolean => + isBrowserNotificationSupported() && Notification.permission === 'granted' && getStoredBoolean(notificationEnabledKey) + +export const isSoundEnabled = (soundEnabledKey: string): boolean => getStoredBoolean(soundEnabledKey) + +export interface UseNotificationPreferencesOptions { + prefix: string +} + +export function useNotificationPreferences(options: UseNotificationPreferencesOptions) { + const { prefix } = options + const notificationEnabledKey = `${prefix}-notification-enabled` + const soundEnabledKey = `${prefix}-sound-enabled` + + const [notificationsEnabled, setNotificationsEnabled] = useLocalStorage(notificationEnabledKey, false) + const [soundEnabled, setSoundEnabled] = useLocalStorage(soundEnabledKey, false) + + const requestPermission = useCallback(async () => { + if (notificationsEnabled) { + await requestNotificationPermission() + } + if (soundEnabled) { + await requestSoundPermission() + } + }, [notificationsEnabled, soundEnabled]) + + const checkNotificationEnabled = useCallback((): boolean => { + return isNotificationEnabled(notificationEnabledKey) + }, [notificationEnabledKey]) + + const checkSoundEnabled = useCallback((): boolean => { + return isSoundEnabled(soundEnabledKey) + }, [soundEnabledKey]) + + return { + notificationsEnabled, + setNotificationsEnabled, + soundEnabled, + setSoundEnabled, + requestPermission, + isNotificationEnabled: checkNotificationEnabled, + isSoundEnabled: checkSoundEnabled, + isBrowserNotificationSupported: isBrowserNotificationSupported(), + soundEnabledKey, + notificationEnabledKey, + } +}