From 92c6e05f85d902cc7e09a27fb230f97a20f9343a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20Grandin?= Date: Thu, 27 Nov 2025 11:30:52 +0100 Subject: [PATCH 01/11] Adding cluster installation progress card --- .../page-overview-feature.spec.tsx | 46 +++- .../page-overview-feature.tsx | 94 +++++++- .../deployment-ongoing-card.tsx | 212 ++++++++++++++++++ 3 files changed, 348 insertions(+), 4 deletions(-) create mode 100644 libs/pages/cluster/src/lib/ui/deployment-ongoing-card/deployment-ongoing-card.tsx 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..145695f529d 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,22 +1,30 @@ import { useClusterMetrics } from '@qovery/domains/cluster-metrics/feature' -import { useCluster, useClusterRunningStatus } from '@qovery/domains/clusters/feature' +import { useCluster, useClusterLogs, useClusterRunningStatus, useClusterStatus } from '@qovery/domains/clusters/feature' +import { useProjects } from '@qovery/domains/projects/feature' +import { ClusterStateEnum } from 'qovery-typescript-axios' 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(), })) jest.mock('@qovery/domains/cluster-metrics/feature', () => ({ useClusterMetrics: jest.fn(), })) +jest.mock('@qovery/domains/projects/feature', () => ({ + useProjects: jest.fn(), +})) + describe('PageOverviewFeature', () => { beforeEach(() => { jest.clearAllMocks() ;(useClusterRunningStatus as jest.Mock).mockReturnValue({ - data: 'NotFound', + data: { computed_status: { global_status: 'RUNNING' } }, }) ;(useCluster as jest.Mock).mockReturnValue({ data: { @@ -34,6 +42,15 @@ describe('PageOverviewFeature', () => { nodes: [], }, }) + ;(useClusterLogs as jest.Mock).mockReturnValue({ + data: [], + }) + ;(useClusterStatus as jest.Mock).mockReturnValue({ + data: { is_deployed: true, status: ClusterStateEnum.DEPLOYED }, + }) + ;(useProjects as jest.Mock).mockReturnValue({ + data: [], + }) }) it('should render successfully', () => { @@ -42,7 +59,32 @@ describe('PageOverviewFeature', () => { }) it('should render placeholder when running status is unavailable', () => { + ;(useClusterRunningStatus as jest.Mock).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', () => { + ;(useClusterStatus as jest.Mock).mockReturnValue({ + data: { is_deployed: false, status: ClusterStateEnum.DEPLOYING }, + }) + ;(useCluster as jest.Mock).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..b937875dae3 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,5 @@ +import clsx from 'clsx' +import { useEffect, useRef, useState } from 'react' import { useParams } from 'react-router-dom' import { ClusterCardNodeUsage, @@ -7,9 +9,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 +38,86 @@ 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 + + useEffect(() => { + if (isDeploymentInProgress) { + hasSeenDeploymentInProgress.current = true + if (hideTimeoutRef.current) { + clearTimeout(hideTimeoutRef.current) + hideTimeoutRef.current = null + } + setShowDeploymentCard(true) + return + } + + if (hasSeenDeploymentInProgress.current && clusterStatus?.is_deployed) { + 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 +132,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..a7dbdb4bed6 --- /dev/null +++ b/libs/pages/cluster/src/lib/ui/deployment-ongoing-card/deployment-ongoing-card.tsx @@ -0,0 +1,212 @@ +import { useEffect, useMemo, useState } from 'react' +import { useLocation } from 'react-router-dom' +import { useClusterLogs } 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 +} + +type StepStatus = 'current' | 'pending' | 'done' + +const DEPLOYMENT_STEPS = [ + 'Validating config', + 'Providing infra (on provider side)', + 'Verifying provided infra', + 'Installing Qovery stack', + 'Verifying kube deprecation API calls', +] + +export function DeploymentOngoingCard({ + organizationId, + clusterId, + clusterName, + cloudProvider, +}: DeploymentOngoingCardProps) { + const { pathname } = useLocation() + const { data: clusterLogs } = useClusterLogs({ + organizationId, + clusterId, + refetchInterval: 3000, + }) + const { data: projects = [] } = useProjects({ organizationId, enabled: !!organizationId }) + + 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 + }>({ highestStepIndex: 0, installationComplete: false }) + + useEffect(() => { + if (!clusterLogs || clusterLogs.length === 0) return + let maxIndex = 0 + let isComplete = false + + for (const log of clusterLogs) { + const message = + (log.error as { user_log_message?: string } | undefined)?.user_log_message ?? log.message?.safe_message ?? '' + if (!message) continue + + if (message.includes('Kubernetes cluster successfully created')) { + maxIndex = DEPLOYMENT_STEPS.length - 1 + isComplete = 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'), + }, + { + index: 1, + match: (msg) => + Boolean(clusterName && providerCode && msg.includes(`Deployment ${providerCode} cluster ${clusterName}`)), + }, + { + index: 2, + match: (msg) => msg.includes('Saved the plan to: tf_plan'), + }, + { + index: 3, + match: (msg) => msg.includes('Preparing Helm files on disk'), + }, + ] + + for (const trigger of triggers) { + if (trigger.match(message)) { + maxIndex = Math.max(maxIndex, trigger.index) + } + } + + if (maxIndex === DEPLOYMENT_STEPS.length - 1) break + } + + setProgress((prev) => ({ + highestStepIndex: Math.max(prev.highestStepIndex, maxIndex), + installationComplete: prev.installationComplete || isComplete, + })) + }, [clusterLogs, clusterName, providerCode]) + + 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 deploymentLink = + installationComplete && projects[0] + ? OVERVIEW_URL(organizationId, projects[0].id) + : INFRA_LOGS_URL(organizationId, clusterId) + const deploymentLinkLabel = installationComplete && projects[0] ? 'Start deploying' : 'Cluster logs' + + return ( +
+
+
+ {installationComplete ? ( + <> + + Cluster installed! + + ) : ( + <> + + Deployment ongoing + + )} +
+
+ + {deploymentLinkLabel} + + +
+
+
+ {steps.map(({ label, status }, index) => ( +
+ {status === 'done' && ( + + )} + {status === 'current' && ( + + )} + {status === 'pending' && ( + + )} + + {label} + + {index < steps.length - 1 && ( + + ⸺ + + )} +
+ ))} +
+
+ ) +} + +export default DeploymentOngoingCard From 15617ec91772b2ff016acc869d932c0985ae37aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20Grandin?= Date: Fri, 28 Nov 2025 10:15:13 +0100 Subject: [PATCH 02/11] feat: adding browser notification approval modal --- libs/domains/clusters/feature/src/index.ts | 7 + .../cluster-notification-permission-modal.tsx | 131 ++++++++++++++++ .../floating-deployment-progress-card.tsx | 147 ++++++++++++++++++ .../use-deployment-progress.ts | 142 +++++++++++++++++ .../deployment-ongoing-card.tsx | 118 +------------- .../step-summary-feature.tsx | 47 +++++- .../src/lib/ui/layout-page/layout-page.tsx | 18 ++- 7 files changed, 491 insertions(+), 119 deletions(-) create mode 100644 libs/domains/clusters/feature/src/lib/deployment-progress/cluster-notification-permission-modal.tsx create mode 100644 libs/domains/clusters/feature/src/lib/deployment-progress/floating-deployment-progress-card.tsx create mode 100644 libs/domains/clusters/feature/src/lib/hooks/use-deployment-progress/use-deployment-progress.ts diff --git a/libs/domains/clusters/feature/src/index.ts b/libs/domains/clusters/feature/src/index.ts index 1935ba3ce7e..40794a73fd4 100644 --- a/libs/domains/clusters/feature/src/index.ts +++ b/libs/domains/clusters/feature/src/index.ts @@ -38,5 +38,12 @@ 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 { + ClusterNotificationPermissionModal, + getNotificationModalSeen, + setNotificationModalSeen, +} from './lib/deployment-progress/cluster-notification-permission-modal' +export { FloatingDeploymentProgressCard } from './lib/deployment-progress/floating-deployment-progress-card' 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/deployment-progress/cluster-notification-permission-modal.tsx b/libs/domains/clusters/feature/src/lib/deployment-progress/cluster-notification-permission-modal.tsx new file mode 100644 index 00000000000..f7fb772fbef --- /dev/null +++ b/libs/domains/clusters/feature/src/lib/deployment-progress/cluster-notification-permission-modal.tsx @@ -0,0 +1,131 @@ +import { useState } from 'react' +import { Button, Icon, InputToggle } from '@qovery/shared/ui' + +const NOTIFICATION_MODAL_SEEN_KEY = 'cluster-notification-permission-modal-seen' + +const requestNotificationPermission = async () => { + if (typeof window === 'undefined' || !('Notification' in window)) return + if (Notification.permission === 'default') { + try { + await Notification.requestPermission() + } catch (error) { + console.error(error) + } + } +} + +const requestSoundPermission = async () => { + if (typeof window === 'undefined') return + const AudioContextConstructor = + window.AudioContext || + (window as typeof window & { webkitAudioContext?: typeof AudioContext }).webkitAudioContext + if (!AudioContextConstructor) return + + const audioContext = new AudioContextConstructor() + if (audioContext.state === 'suspended') { + await audioContext.resume() + } + + // Play a short, quiet tone to unlock audio playback after user confirmation. + const oscillator = audioContext.createOscillator() + const gainNode = audioContext.createGain() + oscillator.type = 'sine' + oscillator.frequency.setValueAtTime(880, audioContext.currentTime) + gainNode.gain.setValueAtTime(0.05, audioContext.currentTime) + oscillator.connect(gainNode) + gainNode.connect(audioContext.destination) + + oscillator.start() + oscillator.stop(audioContext.currentTime + 0.12) + oscillator.onended = () => { + oscillator.disconnect() + gainNode.disconnect() + audioContext.close().catch(() => null) + } +} + +export const getNotificationModalSeen = () => { + if (typeof window === 'undefined') return true + return localStorage.getItem(NOTIFICATION_MODAL_SEEN_KEY) === 'true' +} + +export const setNotificationModalSeen = () => { + if (typeof window === 'undefined') return + localStorage.setItem(NOTIFICATION_MODAL_SEEN_KEY, 'true') +} + +export interface ClusterNotificationPermissionModalProps { + onClose: () => void +} + +export function ClusterNotificationPermissionModal({ onClose }: ClusterNotificationPermissionModalProps) { + const [notificationsEnabled, setNotificationsEnabled] = useState(false) + const [soundEnabled, setSoundEnabled] = useState(false) + const [isConfirming, setIsConfirming] = useState(false) + + const handleConfirm = async () => { + if (!notificationsEnabled && !soundEnabled) { + onClose() + return + } + + setIsConfirming(true) + try { + if (notificationsEnabled) { + await requestNotificationPermission() + } + + if (soundEnabled) { + await requestSoundPermission() + } + } catch (error) { + console.error(error) + } finally { + setIsConfirming(false) + onClose() + } + } + + return ( +
+
+
+ +
+
+

Stay informed while we install your cluster

+

+ Enable alerts so we can notify you when installation completes. You can adjust these permissions in your + browser settings anytime. +

+
+
+ +
+ + +
+ +
+ + +
+
+ ) +} + +export default ClusterNotificationPermissionModal diff --git a/libs/domains/clusters/feature/src/lib/deployment-progress/floating-deployment-progress-card.tsx b/libs/domains/clusters/feature/src/lib/deployment-progress/floating-deployment-progress-card.tsx new file mode 100644 index 00000000000..d25473914c1 --- /dev/null +++ b/libs/domains/clusters/feature/src/lib/deployment-progress/floating-deployment-progress-card.tsx @@ -0,0 +1,147 @@ +import clsx from 'clsx' +import { useState } from 'react' +import { useProjects } from '@qovery/domains/projects/feature' +import { OVERVIEW_URL } from '@qovery/shared/routes' +import { Icon, Link } from '@qovery/shared/ui' +import { useDeploymentProgress } from '../hooks/use-deployment-progress/use-deployment-progress' + +export interface FloatingDeploymentProgressCardProps { + organizationId: string + clusters: { id: string; name?: string; cloud_provider?: string }[] +} + +function ClusterRow({ + organizationId, + clusterId, + clusterName, + cloudProvider, + isSingle, + isFirst, + isLast, +}: { + organizationId: string + clusterId: string + clusterName?: string + cloudProvider?: string + isSingle: boolean + isFirst: boolean + isLast: boolean +}) { + const [expanded, setExpanded] = useState(false) + const { steps, installationComplete, progressValue, currentStepLabel } = useDeploymentProgress({ + organizationId, + clusterId, + clusterName, + cloudProvider, + }) + const { data: projects = [] } = useProjects({ organizationId, enabled: !!organizationId }) + const projectTarget = installationComplete ? projects[0] : undefined + + const rowClasses = isSingle ? '' : clsx(!isLast && 'border-b border-neutral-200') + + const containerClasses = isSingle ? 'rounded-xl' : '' + + return ( +
+
+
+ {installationComplete ? ( + + ) : ( + + )} + {clusterName ?? 'Cluster'} +
+
+ {installationComplete && projectTarget && ( + + Start deploying + + + )} + +
+
+ {expanded && ( +
+
    + {steps.map(({ label, status }) => ( +
  • + {status === 'done' && } + {status === 'current' && ( + + )} + {status === 'pending' && } + + {label} + +
  • + ))} +
+
+ )} +
+ ) +} + +export function FloatingDeploymentProgressCard({ organizationId, clusters }: FloatingDeploymentProgressCardProps) { + if (!clusters.length) return null + const isSingle = clusters.length === 1 + + return ( +
+ {clusters.map((cluster, idx) => ( + + ))} +
+ ) +} + +export default FloatingDeploymentProgressCard 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..7c5eb5038ab --- /dev/null +++ b/libs/domains/clusters/feature/src/lib/hooks/use-deployment-progress/use-deployment-progress.ts @@ -0,0 +1,142 @@ +import { useEffect, useMemo, useState } from 'react' +import { useClusterLogs } from '@qovery/domains/clusters/feature' + +export type StepStatus = 'current' | 'pending' | 'done' + +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 +} + +export function useDeploymentProgress({ + organizationId, + clusterId, + clusterName, + cloudProvider, +}: UseDeploymentProgressProps): { + steps: { label: string; status: StepStatus }[] + installationComplete: boolean + highestStepIndex: number + progressValue: number + currentStepLabel: string +} { + const { data: clusterLogs } = useClusterLogs({ + organizationId, + clusterId, + refetchInterval: 3000, + }) + + 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 + }>({ highestStepIndex: 0, installationComplete: false }) + + useEffect(() => { + if (!clusterLogs || clusterLogs.length === 0) return + let maxIndex = 0 + let isComplete = false + + for (const log of clusterLogs) { + const message = + (log.error as { user_log_message?: string } | undefined)?.user_log_message ?? log.message?.safe_message ?? '' + if (!message) continue + + if (message.includes('Kubernetes cluster successfully created')) { + maxIndex = DEPLOYMENT_STEPS.length - 1 + isComplete = 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'), + }, + { + index: 1, + match: (msg) => + Boolean(clusterName && providerCode && msg.includes(`Deployment ${providerCode} cluster ${clusterName}`)), + }, + { + index: 2, + match: (msg) => msg.includes('Saved the plan to: tf_plan'), + }, + { + index: 3, + match: (msg) => msg.includes('Preparing Helm files on disk'), + }, + ] + + for (const trigger of triggers) { + if (trigger.match(message)) { + maxIndex = Math.max(maxIndex, trigger.index) + } + } + + if (maxIndex === DEPLOYMENT_STEPS.length - 1) break + } + + setProgress((prev) => ({ + highestStepIndex: Math.max(prev.highestStepIndex, maxIndex), + installationComplete: prev.installationComplete || isComplete, + })) + }, [clusterLogs, clusterName, providerCode]) + + 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, + } +} + +export default useDeploymentProgress 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 index a7dbdb4bed6..b3d59e9e2bd 100644 --- 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 @@ -1,6 +1,5 @@ -import { useEffect, useMemo, useState } from 'react' import { useLocation } from 'react-router-dom' -import { useClusterLogs } from '@qovery/domains/clusters/feature' +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' @@ -13,16 +12,6 @@ export interface DeploymentOngoingCardProps { cloudProvider?: string } -type StepStatus = 'current' | 'pending' | 'done' - -const DEPLOYMENT_STEPS = [ - 'Validating config', - 'Providing infra (on provider side)', - 'Verifying provided infra', - 'Installing Qovery stack', - 'Verifying kube deprecation API calls', -] - export function DeploymentOngoingCard({ organizationId, clusterId, @@ -30,104 +19,13 @@ export function DeploymentOngoingCard({ cloudProvider, }: DeploymentOngoingCardProps) { const { pathname } = useLocation() - const { data: clusterLogs } = useClusterLogs({ + const { data: projects = [] } = useProjects({ organizationId, enabled: !!organizationId }) + const { steps, installationComplete, progressValue } = useDeploymentProgress({ organizationId, clusterId, - refetchInterval: 3000, + clusterName, + cloudProvider, }) - const { data: projects = [] } = useProjects({ organizationId, enabled: !!organizationId }) - - 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 - }>({ highestStepIndex: 0, installationComplete: false }) - - useEffect(() => { - if (!clusterLogs || clusterLogs.length === 0) return - let maxIndex = 0 - let isComplete = false - - for (const log of clusterLogs) { - const message = - (log.error as { user_log_message?: string } | undefined)?.user_log_message ?? log.message?.safe_message ?? '' - if (!message) continue - - if (message.includes('Kubernetes cluster successfully created')) { - maxIndex = DEPLOYMENT_STEPS.length - 1 - isComplete = 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'), - }, - { - index: 1, - match: (msg) => - Boolean(clusterName && providerCode && msg.includes(`Deployment ${providerCode} cluster ${clusterName}`)), - }, - { - index: 2, - match: (msg) => msg.includes('Saved the plan to: tf_plan'), - }, - { - index: 3, - match: (msg) => msg.includes('Preparing Helm files on disk'), - }, - ] - - for (const trigger of triggers) { - if (trigger.match(message)) { - maxIndex = Math.max(maxIndex, trigger.index) - } - } - - if (maxIndex === DEPLOYMENT_STEPS.length - 1) break - } - - setProgress((prev) => ({ - highestStepIndex: Math.max(prev.highestStepIndex, maxIndex), - installationComplete: prev.installationComplete || isComplete, - })) - }, [clusterLogs, clusterName, providerCode]) - - 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 deploymentLink = installationComplete && projects[0] @@ -138,7 +36,7 @@ export function DeploymentOngoingCard({ return (
-
+
{installationComplete ? ( <> @@ -159,7 +57,7 @@ export function DeploymentOngoingCard({ strokeLinecap="round" strokeDasharray={2 * Math.PI * 6.25} strokeDashoffset={2 * Math.PI * 6.25 * (1 - progressValue)} - className="transition duration-150 ease-in-out" + className="transition-[stroke-dashoffset] duration-150 ease-in-out" /> @@ -175,7 +73,7 @@ export function DeploymentOngoingCard({
- {steps.map(({ label, status }, index) => ( + {steps.map(({ label, status }: { label: string; status: 'current' | 'pending' | 'done' }, index: number) => (
{status === 'done' && ( 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..257388c02b3 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 @@ -8,7 +8,14 @@ import { useCallback, useEffect } from 'react' 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 { + ClusterNotificationPermissionModal, + getNotificationModalSeen, + setNotificationModalSeen, + useCreateCluster, + useDeployCluster, + useEditCloudProviderInfo, +} from '@qovery/domains/clusters/feature' import { CLUSTERS_CREATION_EKS_URL, CLUSTERS_CREATION_FEATURES_URL, @@ -18,7 +25,7 @@ import { 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 +92,28 @@ export function StepSummaryFeature() { navigate(creationFlowUrl + CLUSTERS_CREATION_EKS_URL) } + const { openModal, closeModal } = useModal() + + const showNotificationModal = (onAfterClose: () => void) => { + if (getNotificationModalSeen()) { + onAfterClose() + return + } + + setNotificationModalSeen() + openModal({ + content: ( + { + closeModal() + onAfterClose() + }} + /> + ), + options: { width: 520 }, + }) + } + const onBack = () => { if (generalData?.installation_type === 'SELF_MANAGED') { goToKubeconfig() @@ -134,10 +163,12 @@ export function StepSummaryFeature() { clusterId: cluster.id, cloudProviderInfoRequest: cloud_provider_credentials, }) - navigate({ - pathname: creationFlowUrl + CLUSTERS_GENERAL_URL, - search: '?show-self-managed-guide', - }) + showNotificationModal(() => + navigate({ + pathname: creationFlowUrl + CLUSTERS_GENERAL_URL, + search: '?show-self-managed-guide', + }) + ) } catch (e) { console.error(e) } @@ -162,7 +193,7 @@ export function StepSummaryFeature() { }, }) - navigate(CLUSTERS_URL(organizationId)) + showNotificationModal(() => navigate(CLUSTERS_URL(organizationId))) } catch (e) { console.error(e) } @@ -361,7 +392,7 @@ export function StepSummaryFeature() { if (withDeploy) { await deployCluster({ clusterId: cluster.id, organizationId }) } - navigate(CLUSTERS_URL(organizationId)) + showNotificationModal(() => navigate(CLUSTERS_URL(organizationId))) } catch (e) { console.error(e) } 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..9b886509ed8 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 @@ -3,7 +3,7 @@ import { type Cluster, ClusterStateEnum, type Organization } from 'qovery-typesc import { type PropsWithChildren, useMemo } from 'react' import { useLocation, useNavigate, useParams } from 'react-router-dom' import { match } from 'ts-pattern' -import { useClusterStatuses } from '@qovery/domains/clusters/feature' +import { FloatingDeploymentProgressCard, useClusterStatuses } 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' @@ -70,6 +70,16 @@ export function LayoutPage(props: PropsWithChildren) { const clusterBanner = !matchLogInfraRoute && clusters && displayClusterDeploymentBanner(firstClusterStatus?.status) && !clusterIsDeployed + const deployingClusters = + clusters?.filter(({ id }) => + clusterStatuses?.some( + ({ cluster_id, status, is_deployed }) => + cluster_id === id && displayClusterDeploymentBanner(status) && !is_deployed + ) + ) || [] + 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 @@ -173,6 +183,12 @@ export function LayoutPage(props: PropsWithChildren) { )}
+ {showFloatingDeploymentCard && organizationId && deployingClusters.length > 0 && ( + + )} ) From 84b4717f39d8a6370ce7efaccebcbff7b368eae7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20Grandin?= Date: Fri, 28 Nov 2025 17:37:09 +0100 Subject: [PATCH 03/11] feat: Notification + sound cues when cluster finished, cluster error handling, scoped to the created users, sound request modal --- libs/domains/clusters/feature/src/index.ts | 7 +- .../cluster-notification-permission-modal.tsx | 134 +++++++++++------- .../floating-deployment-progress-card.tsx | 32 +++-- .../use-cluster-install-notifications.ts | 122 ++++++++++++++++ .../use-deployment-progress.ts | 49 ++++++- .../src/lib/utils/cluster-install-tracking.ts | 41 ++++++ .../deployment-ongoing-card.tsx | 23 ++- .../step-summary-feature.tsx | 40 ++---- .../src/lib/ui/layout-page/layout-page.tsx | 123 +++++++++++++--- .../lib/assets/sound/cluster_completion.mp3 | Bin 0 -> 49581 bytes 10 files changed, 441 insertions(+), 130 deletions(-) create mode 100644 libs/domains/clusters/feature/src/lib/hooks/use-cluster-install-notifications/use-cluster-install-notifications.ts create mode 100644 libs/domains/clusters/feature/src/lib/utils/cluster-install-tracking.ts create mode 100644 libs/shared/ui/src/lib/assets/sound/cluster_completion.mp3 diff --git a/libs/domains/clusters/feature/src/index.ts b/libs/domains/clusters/feature/src/index.ts index 40794a73fd4..474cc0909b2 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-cluster-install-notifications/use-cluster-install-notifications' 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' @@ -39,11 +41,6 @@ export * from './lib/hooks/use-update-karpenter-private-fargate/use-update-karpe 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 { - ClusterNotificationPermissionModal, - getNotificationModalSeen, - setNotificationModalSeen, -} from './lib/deployment-progress/cluster-notification-permission-modal' export { FloatingDeploymentProgressCard } from './lib/deployment-progress/floating-deployment-progress-card' 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/deployment-progress/cluster-notification-permission-modal.tsx b/libs/domains/clusters/feature/src/lib/deployment-progress/cluster-notification-permission-modal.tsx index f7fb772fbef..ba0291288f3 100644 --- a/libs/domains/clusters/feature/src/lib/deployment-progress/cluster-notification-permission-modal.tsx +++ b/libs/domains/clusters/feature/src/lib/deployment-progress/cluster-notification-permission-modal.tsx @@ -1,10 +1,14 @@ -import { useState } from 'react' -import { Button, Icon, InputToggle } from '@qovery/shared/ui' +import { useCallback, useState } from 'react' +import { Button, Icon, InputToggle, useModal } from '@qovery/shared/ui' -const NOTIFICATION_MODAL_SEEN_KEY = 'cluster-notification-permission-modal-seen' +const NOTIFICATION_MODAL_SEEN_KEY = 'cluster-notification-permission-modal-v2-seen' +const NOTIFICATION_ENABLED_KEY = 'cluster-notification-enabled' +const SOUND_ENABLED_KEY = 'cluster-sound-enabled' + +const isBrowserNotificationSupported = () => typeof window !== 'undefined' && 'Notification' in window const requestNotificationPermission = async () => { - if (typeof window === 'undefined' || !('Notification' in window)) return + if (!isBrowserNotificationSupported()) return if (Notification.permission === 'default') { try { await Notification.requestPermission() @@ -15,33 +19,13 @@ const requestNotificationPermission = async () => { } const requestSoundPermission = async () => { - if (typeof window === 'undefined') return - const AudioContextConstructor = - window.AudioContext || - (window as typeof window & { webkitAudioContext?: typeof AudioContext }).webkitAudioContext - if (!AudioContextConstructor) return - - const audioContext = new AudioContextConstructor() - if (audioContext.state === 'suspended') { - await audioContext.resume() - } + // Intentionally left blank: no sound is played, we only record the user's choice. + return +} - // Play a short, quiet tone to unlock audio playback after user confirmation. - const oscillator = audioContext.createOscillator() - const gainNode = audioContext.createGain() - oscillator.type = 'sine' - oscillator.frequency.setValueAtTime(880, audioContext.currentTime) - gainNode.gain.setValueAtTime(0.05, audioContext.currentTime) - oscillator.connect(gainNode) - gainNode.connect(audioContext.destination) - - oscillator.start() - oscillator.stop(audioContext.currentTime + 0.12) - oscillator.onended = () => { - oscillator.disconnect() - gainNode.disconnect() - audioContext.close().catch(() => null) - } +const getStoredBoolean = (key: string) => { + if (typeof window === 'undefined') return false + return localStorage.getItem(key) === 'true' } export const getNotificationModalSeen = () => { @@ -54,16 +38,35 @@ export const setNotificationModalSeen = () => { localStorage.setItem(NOTIFICATION_MODAL_SEEN_KEY, 'true') } +export const setClusterNotificationEnabled = (enabled: boolean) => { + if (typeof window === 'undefined') return + localStorage.setItem(NOTIFICATION_ENABLED_KEY, enabled ? 'true' : 'false') +} + +export const setClusterSoundEnabled = (enabled: boolean) => { + if (typeof window === 'undefined') return + localStorage.setItem(SOUND_ENABLED_KEY, enabled ? 'true' : 'false') +} + +export const isClusterNotificationEnabled = () => + isBrowserNotificationSupported() && Notification.permission === 'granted' && getStoredBoolean(NOTIFICATION_ENABLED_KEY) + +export const isClusterSoundEnabled = () => getStoredBoolean(SOUND_ENABLED_KEY) + export interface ClusterNotificationPermissionModalProps { onClose: () => void } export function ClusterNotificationPermissionModal({ onClose }: ClusterNotificationPermissionModalProps) { - const [notificationsEnabled, setNotificationsEnabled] = useState(false) - const [soundEnabled, setSoundEnabled] = useState(false) + const [notificationsEnabled, setNotificationsEnabled] = useState(() => getStoredBoolean(NOTIFICATION_ENABLED_KEY)) + const [soundEnabled, setSoundEnabled] = useState(() => getStoredBoolean(SOUND_ENABLED_KEY)) const [isConfirming, setIsConfirming] = useState(false) const handleConfirm = async () => { + setNotificationModalSeen() + setClusterNotificationEnabled(notificationsEnabled) + setClusterSoundEnabled(soundEnabled) + if (!notificationsEnabled && !soundEnabled) { onClose() return @@ -87,37 +90,43 @@ export function ClusterNotificationPermissionModal({ onClose }: ClusterNotificat } return ( -
-
-
- -
-
-

Stay informed while we install your cluster

-

- Enable alerts so we can notify you when installation completes. You can adjust these permissions in your - browser settings anytime. -

-
+
+
+

Get notified at completion

+

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

-
+
-
-
diff --git a/libs/domains/clusters/feature/src/lib/hooks/use-cluster-install-notifications/use-cluster-install-notifications.ts b/libs/domains/clusters/feature/src/lib/hooks/use-cluster-install-notifications/use-cluster-install-notifications.ts new file mode 100644 index 00000000000..cd0dc3b93b3 --- /dev/null +++ b/libs/domains/clusters/feature/src/lib/hooks/use-cluster-install-notifications/use-cluster-install-notifications.ts @@ -0,0 +1,122 @@ +import { useEffect, useMemo, useRef } from 'react' +import { type Cluster, type ClusterStatus, ClusterStateEnum } from 'qovery-typescript-axios' +import { useProjects } from '@qovery/domains/projects/feature' +import { CLUSTER_OVERVIEW_URL, CLUSTER_URL, OVERVIEW_URL } from '@qovery/shared/routes' +import { + isClusterNotificationEnabled, + isClusterSoundEnabled, +} from '../../deployment-progress/cluster-notification-permission-modal' +import { + clearTrackedClusterInstall, + getTrackedClusterInstallIds, +} from '../../utils/cluster-install-tracking' + +type ClusterStatusWithFlag = ClusterStatus + +const clusterCompletionSoundUrl = new URL( + '../../../../../../../shared/ui/src/lib/assets/sound/cluster_completion.mp3', + import.meta.url +).toString() + +const playCompletionSound = () => { + if (!isClusterSoundEnabled() || 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 notifiedClustersRef = useRef>(new Set()) + const { data: projects = [] } = useProjects({ organizationId, enabled: !!organizationId }) + const cycleRef = useRef>(new Map()) + const trackedIdsRef = useRef>(new Set(getTrackedClusterInstallIds())) + + const isInstallingStatus = (status?: ClusterStateEnum) => + status === ClusterStateEnum.DEPLOYING || + status === ClusterStateEnum.DEPLOYMENT_QUEUED || + status === ClusterStateEnum.RESTARTING || + status === ClusterStateEnum.RESTART_QUEUED + + const isInstalledStatus = (status?: ClusterStateEnum) => + status === ClusterStateEnum.DEPLOYED || status === ClusterStateEnum.READY + + const shouldTrackStatuses = useMemo( + () => clusterStatuses.some((status) => isInstallingStatus(status?.status) || status?.is_deployed === false), + [clusterStatuses] + ) + + useEffect(() => { + if (!isClusterNotificationEnabled() || !shouldTrackStatuses) return + if (typeof window === 'undefined') return + + clusterStatuses.forEach((status) => { + const clusterId = status?.cluster_id + if (!clusterId) return + if (!trackedIdsRef.current.has(clusterId)) return + + const state = cycleRef.current.get(clusterId) ?? { installing: false, notified: false } + const nowInstalled = status?.is_deployed === true || isInstalledStatus(status?.status) + const nowInstalling = isInstallingStatus(status?.status) || status?.is_deployed === false + + // Entering an install/restart cycle + if (nowInstalling) { + cycleRef.current.set(clusterId, { installing: true, notified: false }) + notifiedClustersRef.current.delete(clusterId) + return + } + + // Notify when a cycle completes + if (!nowInstalled || !state.installing || state.notified || notifiedClustersRef.current.has(clusterId)) { + cycleRef.current.set(clusterId, state) + return + } + + cycleRef.current.set(clusterId, { installing: false, notified: true }) + + notifiedClustersRef.current.add(clusterId) + clearTrackedClusterInstall(clusterId) + trackedIdsRef.current.delete(clusterId) + + const clusterName = 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('Cluster 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() + }) + }, [clusterStatuses, clusters, organizationId, projects, shouldTrackStatuses]) +} + +export default useClusterInstallNotifications 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 index 7c5eb5038ab..a6154f751de 100644 --- 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 @@ -18,6 +18,15 @@ export interface UseDeploymentProgressProps { cloudProvider?: string } +type ProgressCacheEntry = { + highestStepIndex: number + installationComplete: boolean + lastTimestamp?: number +} + +// Module-level cache to survive remounts within the same session +const progressCache = new Map() + export function useDeploymentProgress({ organizationId, clusterId, @@ -29,6 +38,7 @@ export function useDeploymentProgress({ highestStepIndex: number progressValue: number currentStepLabel: string + creationFailed: boolean } { const { data: clusterLogs } = useClusterLogs({ organizationId, @@ -54,12 +64,25 @@ export function useDeploymentProgress({ const [{ highestStepIndex, installationComplete }, setProgress] = useState<{ highestStepIndex: number installationComplete: boolean - }>({ highestStepIndex: 0, installationComplete: false }) + }>(() => progressCache.get(clusterId) ?? { highestStepIndex: 0, installationComplete: false }) + const [creationFailed, setCreationFailed] = 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 message = @@ -72,6 +95,10 @@ export function useDeploymentProgress({ break } + if (log.step === 'CreateError' || message.includes('CreateError')) { + isFailed = true + } + const triggers: { index: number; match: (msg: string) => boolean }[] = [ { index: DEPLOYMENT_STEPS.length - 1, @@ -101,11 +128,24 @@ export function useDeploymentProgress({ 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 + setProgress((prev) => ({ - highestStepIndex: Math.max(prev.highestStepIndex, maxIndex), - installationComplete: prev.installationComplete || isComplete, + highestStepIndex: Math.max(prev.highestStepIndex, nextHighest), + installationComplete: prev.installationComplete || nextComplete, })) - }, [clusterLogs, clusterName, providerCode]) + setCreationFailed((prev) => (sawStartStep ? false : prev) || isFailed) + + progressCache.set(clusterId, { + highestStepIndex: Math.max(cached.highestStepIndex, nextHighest), + installationComplete: cached.installationComplete || nextComplete, + lastTimestamp: latestTimestamp ? new Date(latestTimestamp).getTime() : cached.lastTimestamp, + }) + }, [clusterLogs, clusterId, clusterName, providerCode]) const steps = useMemo(() => { return DEPLOYMENT_STEPS.map((label, index) => { @@ -136,6 +176,7 @@ export function useDeploymentProgress({ highestStepIndex, progressValue, currentStepLabel, + creationFailed, } } 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..4994c5a14d4 --- /dev/null +++ b/libs/domains/clusters/feature/src/lib/utils/cluster-install-tracking.ts @@ -0,0 +1,41 @@ +const TRACK_KEY = 'cluster-install-tracked-ids' +const MAX_AGE_MS = 1000 * 60 * 60 * 24 // 24h safety cleanup + +type TrackedInstall = { id: 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) => { + if (!clusterId) return + const existing = readInstalls().filter((entry) => entry.id !== clusterId) + existing.push({ id: clusterId, 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) 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 index b3d59e9e2bd..9feeee12f46 100644 --- 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 @@ -20,7 +20,7 @@ export function DeploymentOngoingCard({ }: DeploymentOngoingCardProps) { const { pathname } = useLocation() const { data: projects = [] } = useProjects({ organizationId, enabled: !!organizationId }) - const { steps, installationComplete, progressValue } = useDeploymentProgress({ + const { steps, installationComplete, progressValue, creationFailed } = useDeploymentProgress({ organizationId, clusterId, clusterName, @@ -28,16 +28,27 @@ export function DeploymentOngoingCard({ }) const deploymentLink = - installationComplete && projects[0] - ? OVERVIEW_URL(organizationId, projects[0].id) - : INFRA_LOGS_URL(organizationId, clusterId) - const deploymentLinkLabel = installationComplete && projects[0] ? 'Start deploying' : 'Cluster logs' + creationFailed || !projects[0] + ? INFRA_LOGS_URL(organizationId, clusterId) + : installationComplete + ? OVERVIEW_URL(organizationId, projects[0].id) + : INFRA_LOGS_URL(organizationId, clusterId) + const deploymentLinkLabel = creationFailed + ? 'See logs' + : installationComplete && projects[0] + ? 'Start deploying' + : 'Cluster logs' return (
- {installationComplete ? ( + {creationFailed ? ( + <> + + Cluster install failed + + ) : installationComplete ? ( <> Cluster installed! 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 257388c02b3..f0f8e647fe0 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,13 +9,12 @@ 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 { - ClusterNotificationPermissionModal, - getNotificationModalSeen, - setNotificationModalSeen, useCreateCluster, useDeployCluster, useEditCloudProviderInfo, } from '@qovery/domains/clusters/feature' +import { trackClusterInstall } from '@qovery/domains/clusters/feature' +import { useNotificationPermissionModal } from '../../../../../../../domains/clusters/feature/src/lib/deployment-progress/cluster-notification-permission-modal' import { CLUSTERS_CREATION_EKS_URL, CLUSTERS_CREATION_FEATURES_URL, @@ -25,7 +24,7 @@ import { CLUSTERS_GENERAL_URL, CLUSTERS_URL, } from '@qovery/shared/routes' -import { FunnelFlowBody, useModal } from '@qovery/shared/ui' +import { FunnelFlowBody } 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' @@ -92,27 +91,7 @@ export function StepSummaryFeature() { navigate(creationFlowUrl + CLUSTERS_CREATION_EKS_URL) } - const { openModal, closeModal } = useModal() - - const showNotificationModal = (onAfterClose: () => void) => { - if (getNotificationModalSeen()) { - onAfterClose() - return - } - - setNotificationModalSeen() - openModal({ - content: ( - { - closeModal() - onAfterClose() - }} - /> - ), - options: { width: 520 }, - }) - } + const { showNotificationPermissionModal } = useNotificationPermissionModal() const onBack = () => { if (generalData?.installation_type === 'SELF_MANAGED') { @@ -158,12 +137,13 @@ export function StepSummaryFeature() { cloud_provider_credentials, }, }) + trackClusterInstall(cluster.id) await editCloudProviderInfo({ organizationId, clusterId: cluster.id, cloudProviderInfoRequest: cloud_provider_credentials, }) - showNotificationModal(() => + showNotificationPermissionModal(() => navigate({ pathname: creationFlowUrl + CLUSTERS_GENERAL_URL, search: '?show-self-managed-guide', @@ -178,7 +158,7 @@ export function StepSummaryFeature() { // EKS if (generalData.installation_type === 'PARTIALLY_MANAGED') { try { - await createCluster({ + const cluster = await createCluster({ organizationId, clusterRequest: { name: generalData.name, @@ -192,8 +172,9 @@ export function StepSummaryFeature() { infrastructure_charts_parameters: resourcesData?.infrastructure_charts_parameters, }, }) + trackClusterInstall(cluster.id) - showNotificationModal(() => navigate(CLUSTERS_URL(organizationId))) + showNotificationPermissionModal(() => navigate(CLUSTERS_URL(organizationId))) } catch (e) { console.error(e) } @@ -383,6 +364,7 @@ export function StepSummaryFeature() { organizationId, clusterRequest, }) + trackClusterInstall(cluster.id) await editCloudProviderInfo({ organizationId, clusterId: cluster.id, @@ -392,7 +374,7 @@ export function StepSummaryFeature() { if (withDeploy) { await deployCluster({ clusterId: cluster.id, organizationId }) } - showNotificationModal(() => navigate(CLUSTERS_URL(organizationId))) + showNotificationPermissionModal(() => navigate(CLUSTERS_URL(organizationId))) } catch (e) { console.error(e) } 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 9b886509ed8..ee8dd69dc80 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,14 @@ 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, useRef, useState } from 'react' import { useLocation, useNavigate, useParams } from 'react-router-dom' import { match } from 'ts-pattern' -import { FloatingDeploymentProgressCard, useClusterStatuses } from '@qovery/domains/clusters/feature' +import { + FloatingDeploymentProgressCard, + 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 +57,15 @@ 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 [activeDeploymentClusterIds, setActiveDeploymentClusterIds] = useState([]) + const [trackedClusterIds, setTrackedClusterIds] = useState(getTrackedClusterInstallIds()) + const removalTimersRef = useRef>({}) + const { data: clusterStatuses } = useClusterStatuses({ + organizationId, + enabled: !!organizationId, + refetchInterval: shouldPollClusterStatuses ? 5000 : undefined, + }) const { data: organization } = useOrganization({ organizationId }) const { roles, isQoveryAdminUser } = useUserRole() const isFeatureFlag = useFeatureFlagVariantKey('devops-copilot') @@ -71,12 +84,7 @@ export function LayoutPage(props: PropsWithChildren) { !matchLogInfraRoute && clusters && displayClusterDeploymentBanner(firstClusterStatus?.status) && !clusterIsDeployed const deployingClusters = - clusters?.filter(({ id }) => - clusterStatuses?.some( - ({ cluster_id, status, is_deployed }) => - cluster_id === id && displayClusterDeploymentBanner(status) && !is_deployed - ) - ) || [] + clusters?.filter(({ id }) => activeDeploymentClusterIds.includes(id) && trackedClusterIds.includes(id)) || [] const isOnClusterPage = pathname.includes('/cluster/') const showFloatingDeploymentCard = deployingClusters.length > 0 && !isOnClusterPage @@ -115,6 +123,88 @@ 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]) + + // Track deploying clusters and keep them visible briefly after completion + useEffect(() => { + if (!clusterStatuses) return + const installingIds = + clusterStatuses + .filter( + ({ status, is_deployed }) => + displayClusterDeploymentBanner(status) && (is_deployed === false || is_deployed === undefined) + ) + .map(({ cluster_id }) => cluster_id) + .filter((id): id is string => Boolean(id)) || [] + + if (installingIds.length) { + setActiveDeploymentClusterIds((prev) => { + const next = new Set(prev) + installingIds.forEach((id) => next.add(id)) + return Array.from(next) + }) + } + }, [clusterStatuses]) + + useEffect(() => { + if (!clusterStatuses) return + clusterStatuses.forEach(({ cluster_id, status, is_deployed }) => { + if (!cluster_id || !activeDeploymentClusterIds.includes(cluster_id)) return + const isInstalled = + is_deployed === true || status === ClusterStateEnum.DEPLOYED || status === ClusterStateEnum.READY + const isFailed = + status === ClusterStateEnum.DEPLOYMENT_ERROR || + status === ClusterStateEnum.BUILD_ERROR || + status === ClusterStateEnum.DELETE_ERROR + const alreadyScheduled = removalTimersRef.current[cluster_id] + + if ((isInstalled || isFailed) && !alreadyScheduled) { + const timer = window.setTimeout(() => { + setActiveDeploymentClusterIds((prev) => prev.filter((id) => id !== cluster_id)) + delete removalTimersRef.current[cluster_id] + }, 10000) + removalTimersRef.current[cluster_id] = timer + } + }) + + Object.entries(removalTimersRef.current).forEach(([clusterId, timer]) => { + if (!activeDeploymentClusterIds.includes(clusterId)) { + clearTimeout(timer) + delete removalTimersRef.current[clusterId] + } + }) + }, [clusterStatuses, activeDeploymentClusterIds]) + + useEffect( + () => () => { + Object.values(removalTimersRef.current).forEach((timer) => clearTimeout(timer)) + removalTimersRef.current = {} + }, + [] + ) + + useEffect(() => { + if (!clusters?.length) { + setActiveDeploymentClusterIds([]) + return + } + setActiveDeploymentClusterIds((prev) => prev.filter((id) => clusters.some((cluster) => cluster.id === id))) + }, [clusters]) + + useClusterInstallNotifications({ + organizationId, + clusters: clusters ?? [], + clusterStatuses, + }) + return ( <> {displayQoveryAdminBanner && ( @@ -162,16 +252,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 && ( @@ -184,10 +264,7 @@ export function LayoutPage(props: PropsWithChildren) {
{showFloatingDeploymentCard && organizationId && deployingClusters.length > 0 && ( - + )} 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 0000000000000000000000000000000000000000..c4e20c7480ee556852014fc87ccb90e74ec67155 GIT binary patch literal 49581 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 Date: Mon, 1 Dec 2025 11:14:59 +0100 Subject: [PATCH 04/11] Add failure/error flows --- .../floating-deployment-progress-card.tsx | 30 +++++++------- .../use-cluster-install-notifications.ts | 41 ++++++++++++++++++- .../use-cluster-logs/use-cluster-logs.ts | 9 +++- .../use-cluster-statuses.ts | 9 +++- .../use-deployment-progress.ts | 29 +++++++++---- .../page-overview-feature.tsx | 9 +++- .../deployment-ongoing-card.tsx | 2 +- .../src/lib/ui/layout-page/layout-page.tsx | 35 ++++++++++++---- 8 files changed, 129 insertions(+), 35 deletions(-) diff --git a/libs/domains/clusters/feature/src/lib/deployment-progress/floating-deployment-progress-card.tsx b/libs/domains/clusters/feature/src/lib/deployment-progress/floating-deployment-progress-card.tsx index fd060e410fa..8fac206a549 100644 --- a/libs/domains/clusters/feature/src/lib/deployment-progress/floating-deployment-progress-card.tsx +++ b/libs/domains/clusters/feature/src/lib/deployment-progress/floating-deployment-progress-card.tsx @@ -54,8 +54,8 @@ function ClusterRow({ className={`${containerClasses} relative z-10 flex w-full items-center justify-between gap-4 overflow-hidden bg-white p-4 text-sm shadow-sm`} >
- {installationComplete && } - {creationFailed && } + {installationComplete && } + {creationFailed && } {!isDone && ( )} - {clusterName ?? 'Cluster'} + + {clusterName ?? 'Cluster'} {creationFailed ? 'creation failed' : installationComplete ? 'created' : ''} +
{targetLink && targetLabel && ( @@ -84,19 +86,19 @@ function ClusterRow({ )} - + {!isDone && ( + + )}
- {expanded && ( + {expanded && !isDone && (
    {steps.map(({ label, status }) => ( diff --git a/libs/domains/clusters/feature/src/lib/hooks/use-cluster-install-notifications/use-cluster-install-notifications.ts b/libs/domains/clusters/feature/src/lib/hooks/use-cluster-install-notifications/use-cluster-install-notifications.ts index cd0dc3b93b3..04d0dd347bb 100644 --- a/libs/domains/clusters/feature/src/lib/hooks/use-cluster-install-notifications/use-cluster-install-notifications.ts +++ b/libs/domains/clusters/feature/src/lib/hooks/use-cluster-install-notifications/use-cluster-install-notifications.ts @@ -1,7 +1,7 @@ import { useEffect, useMemo, useRef } from 'react' import { type Cluster, type ClusterStatus, ClusterStateEnum } from 'qovery-typescript-axios' import { useProjects } from '@qovery/domains/projects/feature' -import { CLUSTER_OVERVIEW_URL, CLUSTER_URL, OVERVIEW_URL } from '@qovery/shared/routes' +import { CLUSTER_OVERVIEW_URL, CLUSTER_URL, INFRA_LOGS_URL, OVERVIEW_URL } from '@qovery/shared/routes' import { isClusterNotificationEnabled, isClusterSoundEnabled, @@ -10,6 +10,7 @@ import { clearTrackedClusterInstall, getTrackedClusterInstallIds, } from '../../utils/cluster-install-tracking' +import { getCachedDeploymentProgress } from '../use-deployment-progress/use-deployment-progress' type ClusterStatusWithFlag = ClusterStatus @@ -51,6 +52,10 @@ export function useClusterInstallNotifications({ const isInstalledStatus = (status?: ClusterStateEnum) => status === ClusterStateEnum.DEPLOYED || status === ClusterStateEnum.READY + const isFailedStatus = (status?: ClusterStateEnum) => + status === ClusterStateEnum.DEPLOYMENT_ERROR || + status === ClusterStateEnum.BUILD_ERROR || + status === ClusterStateEnum.DELETE_ERROR const shouldTrackStatuses = useMemo( () => clusterStatuses.some((status) => isInstallingStatus(status?.status) || status?.is_deployed === false), @@ -69,6 +74,8 @@ export function useClusterInstallNotifications({ const state = cycleRef.current.get(clusterId) ?? { installing: false, notified: false } const nowInstalled = status?.is_deployed === true || isInstalledStatus(status?.status) const nowInstalling = isInstallingStatus(status?.status) || status?.is_deployed === false + const cachedProgress = getCachedDeploymentProgress(clusterId) + const nowFailed = cachedProgress?.creationFailed || isFailedStatus(status?.status) // Entering an install/restart cycle if (nowInstalling) { @@ -77,6 +84,38 @@ export function useClusterInstallNotifications({ return } + // Notify on failure at end of a cycle + if (nowFailed) { + if (!state.installing || state.notified || notifiedClustersRef.current.has(clusterId)) { + cycleRef.current.set(clusterId, state) + return + } + + cycleRef.current.set(clusterId, { installing: false, notified: true }) + notifiedClustersRef.current.add(clusterId) + clearTrackedClusterInstall(clusterId) + trackedIdsRef.current.delete(clusterId) + + const clusterName = clusters.find((cluster) => cluster.id === clusterId)?.name ?? 'Cluster' + + try { + const notification = new Notification('Cluster 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) + } + + return + } + // Notify when a cycle completes if (!nowInstalled || !state.installing || state.notified || notifiedClustersRef.current.has(clusterId)) { cycleRef.current.set(clusterId, state) 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 index a6154f751de..b8e69cbf431 100644 --- 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 @@ -22,11 +22,15 @@ type ProgressCacheEntry = { highestStepIndex: number installationComplete: boolean lastTimestamp?: number + creationFailed?: boolean } // 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, @@ -44,6 +48,7 @@ export function useDeploymentProgress({ organizationId, clusterId, refetchInterval: 3000, + refetchIntervalInBackground: true, }) const providerCode = useMemo(() => { @@ -85,18 +90,23 @@ export function useDeploymentProgress({ const sawStartStep = clusterLogs.some((log) => log.step && startSteps.has(log.step)) for (const log of clusterLogs) { - const message = - (log.error as { user_log_message?: string } | undefined)?.user_log_message ?? log.message?.safe_message ?? '' - if (!message) continue + 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 (message.includes('Kubernetes cluster successfully created')) { + if (normalizedMessage.includes('kubernetes cluster successfully created')) { maxIndex = DEPLOYMENT_STEPS.length - 1 isComplete = true break } - if (log.step === 'CreateError' || message.includes('CreateError')) { + const isCreateError = + log.step === 'CreateError' || normalizedMessage.includes('createerror') + + if (isCreateError) { isFailed = true + break } const triggers: { index: number; match: (msg: string) => boolean }[] = [ @@ -138,14 +148,19 @@ export function useDeploymentProgress({ highestStepIndex: Math.max(prev.highestStepIndex, nextHighest), installationComplete: prev.installationComplete || nextComplete, })) - setCreationFailed((prev) => (sawStartStep ? false : prev) || isFailed) + const nextFailed = (sawStartStep ? false : creationFailed) || isFailed + if (nextFailed && !creationFailed) { + console.log('[cluster] creation failed detected', { clusterId }) + } + setCreationFailed(nextFailed) progressCache.set(clusterId, { highestStepIndex: Math.max(cached.highestStepIndex, nextHighest), installationComplete: cached.installationComplete || nextComplete, + creationFailed: (sawStartStep ? false : cached.creationFailed) || nextFailed, lastTimestamp: latestTimestamp ? new Date(latestTimestamp).getTime() : cached.lastTimestamp, }) - }, [clusterLogs, clusterId, clusterName, providerCode]) + }, [clusterLogs, clusterId, clusterName, providerCode, creationFailed]) const steps = useMemo(() => { return DEPLOYMENT_STEPS.map((label, index) => { 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 b937875dae3..b4555cdb5fd 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,5 +1,6 @@ import clsx from 'clsx' import { useEffect, useRef, useState } from 'react' +import { ClusterStateEnum } from 'qovery-typescript-axios' import { useParams } from 'react-router-dom' import { ClusterCardNodeUsage, @@ -52,6 +53,12 @@ export function PageOverviewFeature() { 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) { @@ -64,7 +71,7 @@ export function PageOverviewFeature() { return } - if (hasSeenDeploymentInProgress.current && clusterStatus?.is_deployed) { + if (hasSeenDeploymentInProgress.current && (clusterStatus?.is_deployed || isDeploymentFailed)) { if (!hideTimeoutRef.current) { hideTimeoutRef.current = setTimeout(() => { setShowDeploymentCard(false) 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 index 9feeee12f46..dd451e766a2 100644 --- 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 @@ -45,7 +45,7 @@ export function DeploymentOngoingCard({
    {creationFailed ? ( <> - + Cluster install failed ) : installationComplete ? ( 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 ee8dd69dc80..493deb1ebc4 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 @@ -61,10 +61,12 @@ export function LayoutPage(props: PropsWithChildren) { const [activeDeploymentClusterIds, setActiveDeploymentClusterIds] = useState([]) const [trackedClusterIds, setTrackedClusterIds] = useState(getTrackedClusterInstallIds()) const removalTimersRef = useRef>({}) + const dismissedClusterIdsRef = useRef>(new Set()) const { data: clusterStatuses } = useClusterStatuses({ organizationId, enabled: !!organizationId, refetchInterval: shouldPollClusterStatuses ? 5000 : undefined, + refetchIntervalInBackground: true, }) const { data: organization } = useOrganization({ organizationId }) const { roles, isQoveryAdminUser } = useUserRole() @@ -136,19 +138,33 @@ export function LayoutPage(props: PropsWithChildren) { // Track deploying clusters and keep them visible briefly after completion useEffect(() => { if (!clusterStatuses) return - const installingIds = + const isDeploying = ({ status, is_deployed }: { status?: ClusterStateEnum; is_deployed?: boolean }) => + displayClusterDeploymentBanner(status) && (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 + + // Allow re-adding a cluster if a new deployment starts + clusterStatuses.forEach(({ cluster_id, status, is_deployed }) => { + if (!cluster_id) return + if (isDeploying({ status, is_deployed })) { + dismissedClusterIdsRef.current.delete(cluster_id) + } + }) + const idsToAdd = clusterStatuses - .filter( - ({ status, is_deployed }) => - displayClusterDeploymentBanner(status) && (is_deployed === false || is_deployed === undefined) - ) - .map(({ cluster_id }) => cluster_id) - .filter((id): id is string => Boolean(id)) || [] + .filter(({ status, is_deployed }) => isDeploying({ status, is_deployed }) || isTerminal({ status, is_deployed })) + .map(({ cluster_id }) => cluster_id ?? '') + .filter((id): id is string => Boolean(id) && !dismissedClusterIdsRef.current.has(id)) || [] - if (installingIds.length) { + if (idsToAdd.length > 0) { setActiveDeploymentClusterIds((prev) => { const next = new Set(prev) - installingIds.forEach((id) => next.add(id)) + idsToAdd.forEach((id) => next.add(id)) return Array.from(next) }) } @@ -170,6 +186,7 @@ export function LayoutPage(props: PropsWithChildren) { const timer = window.setTimeout(() => { setActiveDeploymentClusterIds((prev) => prev.filter((id) => id !== cluster_id)) delete removalTimersRef.current[cluster_id] + dismissedClusterIdsRef.current.add(cluster_id) }, 10000) removalTimersRef.current[cluster_id] = timer } From ac142d4f0848bcd672a94c621d4947b36c6995e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20Grandin?= Date: Mon, 1 Dec 2025 15:31:59 +0100 Subject: [PATCH 05/11] Added animated text on floating card progress --- libs/domains/clusters/feature/src/index.ts | 1 + .../floating-deployment-progress-card.tsx | 28 ++-- .../use-active-deployment-clusters.ts | 100 +++++++++++++ .../use-cluster-install-notifications.ts | 140 ++++++++---------- .../use-deployment-progress.ts | 38 ++++- .../deployment-ongoing-card.tsx | 82 +++++----- .../src/lib/ui/layout-page/layout-page.tsx | 91 +----------- 7 files changed, 257 insertions(+), 223 deletions(-) create mode 100644 libs/domains/clusters/feature/src/lib/hooks/use-active-deployment-clusters/use-active-deployment-clusters.ts diff --git a/libs/domains/clusters/feature/src/index.ts b/libs/domains/clusters/feature/src/index.ts index 474cc0909b2..39428577ed1 100644 --- a/libs/domains/clusters/feature/src/index.ts +++ b/libs/domains/clusters/feature/src/index.ts @@ -22,6 +22,7 @@ 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-cluster-install-notifications/use-cluster-install-notifications' +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' diff --git a/libs/domains/clusters/feature/src/lib/deployment-progress/floating-deployment-progress-card.tsx b/libs/domains/clusters/feature/src/lib/deployment-progress/floating-deployment-progress-card.tsx index 8fac206a549..96eac234cd0 100644 --- a/libs/domains/clusters/feature/src/lib/deployment-progress/floating-deployment-progress-card.tsx +++ b/libs/domains/clusters/feature/src/lib/deployment-progress/floating-deployment-progress-card.tsx @@ -3,6 +3,7 @@ import { useState } from 'react' import { useProjects } from '@qovery/domains/projects/feature' import { INFRA_LOGS_URL, OVERVIEW_URL } from '@qovery/shared/routes' import { Icon, Link } from '@qovery/shared/ui' +import { AnimatedGradientText } from '@qovery/shared/ui' import { useDeploymentProgress } from '../hooks/use-deployment-progress/use-deployment-progress' export interface FloatingDeploymentProgressCardProps { @@ -28,25 +29,28 @@ function ClusterRow({ isLast: boolean }) { const [expanded, setExpanded] = useState(false) - const { steps, installationComplete, progressValue, currentStepLabel, creationFailed } = useDeploymentProgress({ + const { steps, progressValue, currentStepLabel, state } = useDeploymentProgress({ organizationId, clusterId, clusterName, cloudProvider, }) const { data: projects = [] } = useProjects({ organizationId, enabled: !!organizationId }) - const projectTarget = installationComplete ? projects[0] : undefined + const projectTarget = state === 'succeeded' ? projects[0] : undefined const rowClasses = isSingle ? '' : clsx(!isLast && 'border-b border-neutral-200') const containerClasses = isSingle ? 'rounded-xl' : '' - const isDone = installationComplete || creationFailed - const targetLink = creationFailed + const isInstalling = state === 'installing' + const isFailed = state === 'failed' + const isSucceeded = state === 'succeeded' + const isDone = isFailed || isSucceeded + const targetLink = isFailed ? INFRA_LOGS_URL(organizationId, clusterId) : projectTarget ? OVERVIEW_URL(organizationId, projectTarget.id) : undefined - const targetLabel = creationFailed ? 'See logs' : projectTarget ? 'Start deploying' : undefined + const targetLabel = isFailed ? 'See logs' : projectTarget ? 'See project' : undefined return (
    @@ -54,9 +58,9 @@ function ClusterRow({ className={`${containerClasses} relative z-10 flex w-full items-center justify-between gap-4 overflow-hidden bg-white p-4 text-sm shadow-sm`} >
    - {installationComplete && } - {creationFailed && } - {!isDone && ( + {isSucceeded && } + {isFailed && } + {isInstalling && ( )} - {clusterName ?? 'Cluster'} {creationFailed ? 'creation failed' : installationComplete ? 'created' : ''} + {clusterName ?? 'Cluster'} {isFailed ? 'creation failed' : isSucceeded ? 'created' : ''}
    @@ -86,13 +90,15 @@ function ClusterRow({ )} - {!isDone && ( + {isInstalling && ( )} 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..71f3ae172fe --- /dev/null +++ b/libs/domains/clusters/feature/src/lib/hooks/use-active-deployment-clusters/use-active-deployment-clusters.ts @@ -0,0 +1,100 @@ +import { useEffect, useMemo, useRef, useState } from 'react' +import { type ClusterStatus, ClusterStateEnum } from 'qovery-typescript-axios' + +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-install-notifications/use-cluster-install-notifications.ts b/libs/domains/clusters/feature/src/lib/hooks/use-cluster-install-notifications/use-cluster-install-notifications.ts index 04d0dd347bb..25dba616530 100644 --- a/libs/domains/clusters/feature/src/lib/hooks/use-cluster-install-notifications/use-cluster-install-notifications.ts +++ b/libs/domains/clusters/feature/src/lib/hooks/use-cluster-install-notifications/use-cluster-install-notifications.ts @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useRef } from 'react' +import { useEffect, useRef } from 'react' import { type Cluster, type ClusterStatus, ClusterStateEnum } from 'qovery-typescript-axios' import { useProjects } from '@qovery/domains/projects/feature' import { CLUSTER_OVERVIEW_URL, CLUSTER_URL, INFRA_LOGS_URL, OVERVIEW_URL } from '@qovery/shared/routes' @@ -10,7 +10,7 @@ import { clearTrackedClusterInstall, getTrackedClusterInstallIds, } from '../../utils/cluster-install-tracking' -import { getCachedDeploymentProgress } from '../use-deployment-progress/use-deployment-progress' +import { getCachedDeploymentProgress, type LifecycleState } from '../use-deployment-progress/use-deployment-progress' type ClusterStatusWithFlag = ClusterStatus @@ -41,57 +41,45 @@ export function useClusterInstallNotifications({ }) { const notifiedClustersRef = useRef>(new Set()) const { data: projects = [] } = useProjects({ organizationId, enabled: !!organizationId }) - const cycleRef = useRef>(new Map()) const trackedIdsRef = useRef>(new Set(getTrackedClusterInstallIds())) - - const isInstallingStatus = (status?: ClusterStateEnum) => - status === ClusterStateEnum.DEPLOYING || - status === ClusterStateEnum.DEPLOYMENT_QUEUED || - status === ClusterStateEnum.RESTARTING || - status === ClusterStateEnum.RESTART_QUEUED - - const isInstalledStatus = (status?: ClusterStateEnum) => - status === ClusterStateEnum.DEPLOYED || status === ClusterStateEnum.READY - const isFailedStatus = (status?: ClusterStateEnum) => - status === ClusterStateEnum.DEPLOYMENT_ERROR || - status === ClusterStateEnum.BUILD_ERROR || - status === ClusterStateEnum.DELETE_ERROR - - const shouldTrackStatuses = useMemo( - () => clusterStatuses.some((status) => isInstallingStatus(status?.status) || status?.is_deployed === false), - [clusterStatuses] - ) + const prevStateRef = useRef>(new Map()) + + 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' + } useEffect(() => { - if (!isClusterNotificationEnabled() || !shouldTrackStatuses) return + if (!isClusterNotificationEnabled()) return if (typeof window === 'undefined') return clusterStatuses.forEach((status) => { const clusterId = status?.cluster_id - if (!clusterId) return - if (!trackedIdsRef.current.has(clusterId)) return - - const state = cycleRef.current.get(clusterId) ?? { installing: false, notified: false } - const nowInstalled = status?.is_deployed === true || isInstalledStatus(status?.status) - const nowInstalling = isInstallingStatus(status?.status) || status?.is_deployed === false - const cachedProgress = getCachedDeploymentProgress(clusterId) - const nowFailed = cachedProgress?.creationFailed || isFailedStatus(status?.status) - - // Entering an install/restart cycle - if (nowInstalling) { - cycleRef.current.set(clusterId, { installing: true, notified: false }) - notifiedClustersRef.current.delete(clusterId) - return - } + if (!clusterId || !trackedIdsRef.current.has(clusterId)) return - // Notify on failure at end of a cycle - if (nowFailed) { - if (!state.installing || state.notified || notifiedClustersRef.current.has(clusterId)) { - cycleRef.current.set(clusterId, state) - return - } + const cachedState = getCachedDeploymentProgress(clusterId)?.state + const derivedState = cachedState ?? deriveStateFromStatus(status?.status, status?.is_deployed) + const prevState = prevStateRef.current.get(clusterId) ?? 'idle' - cycleRef.current.set(clusterId, { installing: false, notified: true }) + const transitionedToSuccess = prevState !== 'succeeded' && derivedState === 'succeeded' + const transitionedToFailure = prevState !== 'failed' && derivedState === 'failed' + + if (transitionedToFailure) { notifiedClustersRef.current.add(clusterId) clearTrackedClusterInstall(clusterId) trackedIdsRef.current.delete(clusterId) @@ -112,50 +100,42 @@ export function useClusterInstallNotifications({ } 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) - return - } + const clusterName = 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.` - // Notify when a cycle completes - if (!nowInstalled || !state.installing || state.notified || notifiedClustersRef.current.has(clusterId)) { - cycleRef.current.set(clusterId, state) - return - } + try { + const notification = new Notification('Cluster installed', { + body, + tag: clusterId, + }) - cycleRef.current.set(clusterId, { installing: false, notified: true }) - - notifiedClustersRef.current.add(clusterId) - clearTrackedClusterInstall(clusterId) - trackedIdsRef.current.delete(clusterId) - - const clusterName = 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('Cluster 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 + 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) } - } catch (error) { - console.error('Unable to show cluster installation notification', error) + + playCompletionSound() } - playCompletionSound() + prevStateRef.current.set(clusterId, derivedState) }) - }, [clusterStatuses, clusters, organizationId, projects, shouldTrackStatuses]) + }, [clusterStatuses, clusters, organizationId, projects]) } export default useClusterInstallNotifications 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 index b8e69cbf431..8a4b0eb0999 100644 --- 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 @@ -2,6 +2,7 @@ import { useEffect, useMemo, useState } from 'react' import { useClusterLogs } from '@qovery/domains/clusters/feature' export type StepStatus = 'current' | 'pending' | 'done' +export type LifecycleState = 'idle' | 'installing' | 'succeeded' | 'failed' export const DEPLOYMENT_STEPS = [ 'Validating configuration', @@ -23,6 +24,8 @@ type ProgressCacheEntry = { installationComplete: boolean lastTimestamp?: number creationFailed?: boolean + state?: LifecycleState + prevState?: LifecycleState } // Module-level cache to survive remounts within the same session @@ -43,6 +46,9 @@ export function useDeploymentProgress({ progressValue: number currentStepLabel: string creationFailed: boolean + state: LifecycleState + justSucceeded: boolean + justFailed: boolean } { const { data: clusterLogs } = useClusterLogs({ organizationId, @@ -71,6 +77,9 @@ export function useDeploymentProgress({ 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 @@ -144,23 +153,35 @@ export function useDeploymentProgress({ const nextHighest = Math.max(baseHighest, maxIndex) const nextComplete = baseComplete || isComplete - setProgress((prev) => ({ - highestStepIndex: Math.max(prev.highestStepIndex, nextHighest), - installationComplete: prev.installationComplete || nextComplete, - })) + 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, nextHighest), - installationComplete: cached.installationComplete || nextComplete, + 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]) + }, [clusterLogs, clusterId, clusterName, providerCode, creationFailed, highestStepIndex, installationComplete, state]) const steps = useMemo(() => { return DEPLOYMENT_STEPS.map((label, index) => { @@ -192,6 +213,9 @@ export function useDeploymentProgress({ progressValue, currentStepLabel, creationFailed, + state, + justSucceeded, + justFailed, } } 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 index dd451e766a2..e19c0d48ed0 100644 --- 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 @@ -20,35 +20,35 @@ export function DeploymentOngoingCard({ }: DeploymentOngoingCardProps) { const { pathname } = useLocation() const { data: projects = [] } = useProjects({ organizationId, enabled: !!organizationId }) - const { steps, installationComplete, progressValue, creationFailed } = useDeploymentProgress({ + const { steps, progressValue, state } = useDeploymentProgress({ organizationId, clusterId, clusterName, cloudProvider, }) + const isInstalling = state === 'installing' + const isFailed = state === 'failed' + const isSucceeded = state === 'succeeded' + const deploymentLink = - creationFailed || !projects[0] + isFailed || !projects[0] ? INFRA_LOGS_URL(organizationId, clusterId) - : installationComplete + : isSucceeded ? OVERVIEW_URL(organizationId, projects[0].id) : INFRA_LOGS_URL(organizationId, clusterId) - const deploymentLinkLabel = creationFailed - ? 'See logs' - : installationComplete && projects[0] - ? 'Start deploying' - : 'Cluster logs' + const deploymentLinkLabel = isFailed ? 'See logs' : isSucceeded && projects[0] ? 'Start deploying' : 'Cluster logs' return (
    - {creationFailed ? ( + {isFailed ? ( <> Cluster install failed - ) : installationComplete ? ( + ) : isSucceeded ? ( <> Cluster installed! @@ -83,37 +83,39 @@ export function DeploymentOngoingCard({
    -
    - {steps.map(({ label, status }: { label: string; status: 'current' | 'pending' | 'done' }, index: number) => ( -
    - {status === 'done' && ( - - )} - {status === 'current' && ( - - )} - {status === 'pending' && ( - - )} - - {label} - - {index < steps.length - 1 && ( - - ⸺ + {isInstalling && ( +
    + {steps.map(({ label, status }: { label: string; status: 'current' | 'pending' | 'done' }, index: number) => ( +
    + {status === 'done' && ( + + )} + {status === 'current' && ( + + )} + {status === 'pending' && ( + + )} + + {label} - )} -
    - ))} -
    + {index < steps.length - 1 && ( + + ⸺ + + )} +
    + ))} +
    + )}
    ) } 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 493deb1ebc4..67e9e423a43 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,10 +1,11 @@ import { useFeatureFlagVariantKey } from 'posthog-js/react' import { type Cluster, ClusterStateEnum, type Organization } from 'qovery-typescript-axios' -import { type PropsWithChildren, useEffect, useMemo, useRef, useState } from 'react' +import { type PropsWithChildren, useEffect, useMemo, useState } from 'react' import { useLocation, useNavigate, useParams } from 'react-router-dom' import { match } from 'ts-pattern' import { FloatingDeploymentProgressCard, + useActiveDeploymentClusters, useClusterInstallNotifications, useClusterStatuses, } from '@qovery/domains/clusters/feature' @@ -58,16 +59,17 @@ export function LayoutPage(props: PropsWithChildren) { const { pathname } = useLocation() const navigate = useNavigate() const [shouldPollClusterStatuses, setShouldPollClusterStatuses] = useState(false) - const [activeDeploymentClusterIds, setActiveDeploymentClusterIds] = useState([]) const [trackedClusterIds, setTrackedClusterIds] = useState(getTrackedClusterInstallIds()) - const removalTimersRef = useRef>({}) - const dismissedClusterIdsRef = useRef>(new Set()) 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') @@ -135,87 +137,6 @@ export function LayoutPage(props: PropsWithChildren) { setTrackedClusterIds(getTrackedClusterInstallIds()) }, [clusterStatuses]) - // Track deploying clusters and keep them visible briefly after completion - useEffect(() => { - if (!clusterStatuses) return - const isDeploying = ({ status, is_deployed }: { status?: ClusterStateEnum; is_deployed?: boolean }) => - displayClusterDeploymentBanner(status) && (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 - - // Allow re-adding a cluster if a new deployment starts - clusterStatuses.forEach(({ cluster_id, status, is_deployed }) => { - if (!cluster_id) return - if (isDeploying({ status, is_deployed })) { - dismissedClusterIdsRef.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) && !dismissedClusterIdsRef.current.has(id)) || [] - - if (idsToAdd.length > 0) { - setActiveDeploymentClusterIds((prev) => { - const next = new Set(prev) - idsToAdd.forEach((id) => next.add(id)) - return Array.from(next) - }) - } - }, [clusterStatuses]) - - useEffect(() => { - if (!clusterStatuses) return - clusterStatuses.forEach(({ cluster_id, status, is_deployed }) => { - if (!cluster_id || !activeDeploymentClusterIds.includes(cluster_id)) return - const isInstalled = - is_deployed === true || status === ClusterStateEnum.DEPLOYED || status === ClusterStateEnum.READY - const isFailed = - status === ClusterStateEnum.DEPLOYMENT_ERROR || - status === ClusterStateEnum.BUILD_ERROR || - status === ClusterStateEnum.DELETE_ERROR - const alreadyScheduled = removalTimersRef.current[cluster_id] - - if ((isInstalled || isFailed) && !alreadyScheduled) { - const timer = window.setTimeout(() => { - setActiveDeploymentClusterIds((prev) => prev.filter((id) => id !== cluster_id)) - delete removalTimersRef.current[cluster_id] - dismissedClusterIdsRef.current.add(cluster_id) - }, 10000) - removalTimersRef.current[cluster_id] = timer - } - }) - - Object.entries(removalTimersRef.current).forEach(([clusterId, timer]) => { - if (!activeDeploymentClusterIds.includes(clusterId)) { - clearTimeout(timer) - delete removalTimersRef.current[clusterId] - } - }) - }, [clusterStatuses, activeDeploymentClusterIds]) - - useEffect( - () => () => { - Object.values(removalTimersRef.current).forEach((timer) => clearTimeout(timer)) - removalTimersRef.current = {} - }, - [] - ) - - useEffect(() => { - if (!clusters?.length) { - setActiveDeploymentClusterIds([]) - return - } - setActiveDeploymentClusterIds((prev) => prev.filter((id) => clusters.some((cluster) => cluster.id === id))) - }, [clusters]) - useClusterInstallNotifications({ organizationId, clusters: clusters ?? [], From 37b0123516a212a371c03eaafd3e1e43dabc2a7f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20Grandin?= Date: Tue, 2 Dec 2025 16:39:21 +0100 Subject: [PATCH 06/11] Fix message step and browser notifications --- __mocks__/fileMock.js | 1 + jest.preset.js | 3 + libs/domains/clusters/feature/src/index.ts | 1 + .../cluster-notification-permission-modal.tsx | 4 +- .../floating-deployment-progress-card.tsx | 15 +- .../use-active-deployment-clusters.ts | 6 +- .../use-cluster-install-notifications.ts | 155 ++++++++++++++++-- .../use-deployment-progress.ts | 23 ++- .../src/lib/utils/cluster-install-tracking.ts | 9 +- .../clusters/feature/src/types/sound.d.ts | 4 + .../page-overview-feature.spec.tsx | 45 +++-- .../page-overview-feature.tsx | 2 +- .../step-summary-feature.tsx | 14 +- .../devops-copilot-panel.tsx | 6 +- 14 files changed, 229 insertions(+), 59 deletions(-) create mode 100644 __mocks__/fileMock.js create mode 100644 libs/domains/clusters/feature/src/types/sound.d.ts diff --git a/__mocks__/fileMock.js b/__mocks__/fileMock.js new file mode 100644 index 00000000000..0e56c5b5f76 --- /dev/null +++ b/__mocks__/fileMock.js @@ -0,0 +1 @@ +module.exports = 'test-file-stub' diff --git a/jest.preset.js b/jest.preset.js index 09b708d7bd9..d3d0901ceb8 100644 --- a/jest.preset.js +++ b/jest.preset.js @@ -1,6 +1,8 @@ const nxPreset = require('@nx/jest/preset').default const path = require('path') +const fileMock = path.join(__dirname, '__mocks__/fileMock.js') + module.exports = { setupFilesAfterEnv: [path.join(__dirname, '__tests__/mocks.ts'), 'jest-canvas-mock'], collectCoverage: true, @@ -13,6 +15,7 @@ module.exports = { ], moduleNameMapper: { '^.+\\.module\\.(css|sass|scss)$': 'identity-obj-proxy', + '\\.(mp3)$': fileMock, }, resetMocks: true, ...nxPreset, diff --git a/libs/domains/clusters/feature/src/index.ts b/libs/domains/clusters/feature/src/index.ts index 39428577ed1..7ff8ae120af 100644 --- a/libs/domains/clusters/feature/src/index.ts +++ b/libs/domains/clusters/feature/src/index.ts @@ -43,5 +43,6 @@ 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 { FloatingDeploymentProgressCard } from './lib/deployment-progress/floating-deployment-progress-card' +export { useNotificationPermissionModal } from './lib/deployment-progress/cluster-notification-permission-modal' 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/deployment-progress/cluster-notification-permission-modal.tsx b/libs/domains/clusters/feature/src/lib/deployment-progress/cluster-notification-permission-modal.tsx index ba0291288f3..9924f64c0e4 100644 --- a/libs/domains/clusters/feature/src/lib/deployment-progress/cluster-notification-permission-modal.tsx +++ b/libs/domains/clusters/feature/src/lib/deployment-progress/cluster-notification-permission-modal.tsx @@ -49,7 +49,9 @@ export const setClusterSoundEnabled = (enabled: boolean) => { } export const isClusterNotificationEnabled = () => - isBrowserNotificationSupported() && Notification.permission === 'granted' && getStoredBoolean(NOTIFICATION_ENABLED_KEY) + isBrowserNotificationSupported() && + Notification.permission === 'granted' && + getStoredBoolean(NOTIFICATION_ENABLED_KEY) export const isClusterSoundEnabled = () => getStoredBoolean(SOUND_ENABLED_KEY) diff --git a/libs/domains/clusters/feature/src/lib/deployment-progress/floating-deployment-progress-card.tsx b/libs/domains/clusters/feature/src/lib/deployment-progress/floating-deployment-progress-card.tsx index 96eac234cd0..57c89a6cc41 100644 --- a/libs/domains/clusters/feature/src/lib/deployment-progress/floating-deployment-progress-card.tsx +++ b/libs/domains/clusters/feature/src/lib/deployment-progress/floating-deployment-progress-card.tsx @@ -50,7 +50,7 @@ function ClusterRow({ : projectTarget ? OVERVIEW_URL(organizationId, projectTarget.id) : undefined - const targetLabel = isFailed ? 'See logs' : projectTarget ? 'See project' : undefined + const targetLabel = isFailed ? 'See logs' : projectTarget ? 'Start deploying' : undefined return (
    @@ -79,6 +79,9 @@ function ClusterRow({ )} + {!isSucceeded && !isFailed && !isInstalling && ( + + )} {clusterName ?? 'Cluster'} {isFailed ? 'creation failed' : isSucceeded ? 'created' : ''} @@ -96,12 +99,16 @@ function ClusterRow({ className="flex min-w-0 items-center gap-2 text-ssm text-neutral-350 hover:text-neutral-400" onClick={() => setExpanded((prev) => !prev)} > - - {currentStepLabel} - {' '} + + + {currentStepLabel} + + + )} + {!isSucceeded && !isFailed && !isInstalling &&

    Deployment queued

    }
    {expanded && !isDone && ( 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 index 71f3ae172fe..f74af8f20d0 100644 --- 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 @@ -1,5 +1,5 @@ +import { ClusterStateEnum, type ClusterStatus } from 'qovery-typescript-axios' import { useEffect, useMemo, useRef, useState } from 'react' -import { type ClusterStatus, ClusterStateEnum } from 'qovery-typescript-axios' interface UseActiveDeploymentClustersProps { clusterStatuses?: ClusterStatus[] @@ -43,7 +43,9 @@ export function useActiveDeploymentClusters({ const idsToAdd = clusterStatuses - .filter(({ status, is_deployed }) => isDeploying({ status, is_deployed }) || isTerminal({ status, is_deployed })) + .filter( + ({ status, is_deployed }) => isDeploying({ status, is_deployed }) || isTerminal({ status, is_deployed }) + ) .map(({ cluster_id }) => cluster_id ?? '') .filter((id): id is string => Boolean(id)) || [] diff --git a/libs/domains/clusters/feature/src/lib/hooks/use-cluster-install-notifications/use-cluster-install-notifications.ts b/libs/domains/clusters/feature/src/lib/hooks/use-cluster-install-notifications/use-cluster-install-notifications.ts index 25dba616530..4e5407ed273 100644 --- a/libs/domains/clusters/feature/src/lib/hooks/use-cluster-install-notifications/use-cluster-install-notifications.ts +++ b/libs/domains/clusters/feature/src/lib/hooks/use-cluster-install-notifications/use-cluster-install-notifications.ts @@ -1,23 +1,20 @@ -import { useEffect, useRef } from 'react' -import { type Cluster, type ClusterStatus, ClusterStateEnum } from 'qovery-typescript-axios' +import { useQueryClient } from '@tanstack/react-query' +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 { QOVERY_WS } from '@qovery/shared/util-node-env' +import { queries, useReactQueryWsSubscription } from '@qovery/state/util-queries' import { isClusterNotificationEnabled, isClusterSoundEnabled, } from '../../deployment-progress/cluster-notification-permission-modal' -import { - clearTrackedClusterInstall, - getTrackedClusterInstallIds, -} from '../../utils/cluster-install-tracking' -import { getCachedDeploymentProgress, type LifecycleState } from '../use-deployment-progress/use-deployment-progress' +import { clearTrackedClusterInstall, getTrackedClusterInstalls } from '../../utils/cluster-install-tracking' +import { type LifecycleState, getCachedDeploymentProgress } from '../use-deployment-progress/use-deployment-progress' type ClusterStatusWithFlag = ClusterStatus -const clusterCompletionSoundUrl = new URL( - '../../../../../../../shared/ui/src/lib/assets/sound/cluster_completion.mp3', - import.meta.url -).toString() +const clusterCompletionSoundUrl = '/assets/sound/cluster_completion.mp3' const playCompletionSound = () => { if (!isClusterSoundEnabled() || typeof Audio === 'undefined') return @@ -41,8 +38,19 @@ export function useClusterInstallNotifications({ }) { const notifiedClustersRef = useRef>(new Set()) const { data: projects = [] } = useProjects({ organizationId, enabled: !!organizationId }) - const trackedIdsRef = useRef>(new Set(getTrackedClusterInstallIds())) + const [trackedInstalls, setTrackedInstalls] = useState(() => + typeof window === 'undefined' ? [] : getTrackedClusterInstalls() + ) + const trackedIdsRef = useRef>(new Set(trackedInstalls.map(({ id }) => id))) const prevStateRef = useRef>(new Map()) + const seenProgressRef = useRef>(new Set()) + const queryClient = useQueryClient() + + 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 ( @@ -64,9 +72,104 @@ export function useClusterInstallNotifications({ 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) + } + }, []) + + // Background-friendly refetch while the tab is hidden so notifications fire even when unfocused + useEffect(() => { + if (typeof document === 'undefined' || typeof window === 'undefined') return + if (!organizationId) return + + const queryKey = queries.clusters.listStatuses({ organizationId }).queryKey + let intervalId: ReturnType | undefined + + const stopInterval = () => { + if (intervalId) { + clearInterval(intervalId) + intervalId = undefined + } + } + + const refetchStatuses = () => { + if (trackedIdsRef.current.size === 0) { + stopInterval() + return + } + void queryClient.invalidateQueries({ queryKey }) + } + + const handleVisibility = () => { + if (document.visibilityState === 'hidden' && trackedIdsRef.current.size > 0) { + refetchStatuses() // kick once on hide + if (!intervalId) { + intervalId = setInterval(refetchStatuses, 15_000) + } + } else { + stopInterval() + } + } + + handleVisibility() + document.addEventListener('visibilitychange', handleVisibility) + + return () => { + stopInterval() + document.removeEventListener('visibilitychange', handleVisibility) + } + }, [organizationId, queryClient, trackedInstalls.length]) + + // WebSocket subscription scoped to tracked clusters to avoid timer throttling when unfocused + useReactQueryWsSubscription({ + url: QOVERY_WS + '/cluster/status', + urlSearchParams: { organization: organizationId }, + enabled: Boolean(organizationId) && trackedInstalls.length > 0, + onMessage(queryClientFromWs, message: ClusterStatus) { + const clusterId = (message as ClusterStatus).cluster_id + if (!clusterId || !trackedIdsRef.current.has(clusterId)) return + + queryClientFromWs.setQueryData( + queries.clusters.listStatuses({ organizationId }).queryKey, + (prev: ClusterStatus[] | undefined) => { + if (!prev) return [message] + let found = false + const next = prev.map((status) => { + if (status.cluster_id === clusterId) { + found = true + return { ...status, ...message } + } + return status + }) + return found ? next : [...next, message] + } + ) + }, + }) + useEffect(() => { if (!isClusterNotificationEnabled()) 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 @@ -75,19 +178,31 @@ export function useClusterInstallNotifications({ const cachedState = getCachedDeploymentProgress(clusterId)?.state const derivedState = cachedState ?? deriveStateFromStatus(status?.status, status?.is_deployed) const prevState = prevStateRef.current.get(clusterId) ?? 'idle' + const hasSeenProgress = seenProgressRef.current.has(clusterId) + + if (derivedState === 'installing') { + seenProgressRef.current.add(clusterId) + } - const transitionedToSuccess = prevState !== 'succeeded' && derivedState === 'succeeded' + const transitionedToSuccess = + derivedState === 'succeeded' && prevState !== 'succeeded' && (hasSeenProgress || prevState === 'installing') const transitionedToFailure = prevState !== 'failed' && derivedState === 'failed' if (transitionedToFailure) { notifiedClustersRef.current.add(clusterId) clearTrackedClusterInstall(clusterId) trackedIdsRef.current.delete(clusterId) + setTrackedInstalls((prev) => { + const next = prev.filter((install) => install.id !== clusterId) + trackedIdsRef.current = new Set(next.map(({ id }) => id)) + return next + }) - const clusterName = clusters.find((cluster) => cluster.id === clusterId)?.name ?? 'Cluster' + const clusterName = + trackedNames.get(clusterId) ?? clusters.find((cluster) => cluster.id === clusterId)?.name ?? 'Cluster' try { - const notification = new Notification('Cluster installation failed', { + const notification = new Notification(`${clusterName} installation failed`, { body: `${clusterName} installation failed. Check cluster logs for details.`, tag: clusterId, }) @@ -104,15 +219,21 @@ export function useClusterInstallNotifications({ notifiedClustersRef.current.add(clusterId) clearTrackedClusterInstall(clusterId) trackedIdsRef.current.delete(clusterId) + setTrackedInstalls((prev) => { + const next = prev.filter((install) => install.id !== clusterId) + trackedIdsRef.current = new Set(next.map(({ id }) => id)) + return next + }) - const clusterName = clusters.find((cluster) => cluster.id === clusterId)?.name ?? 'Cluster' + 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('Cluster installed', { + const notification = new Notification(`${clusterName} installed`, { body, tag: clusterId, }) 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 index 8a4b0eb0999..2f0882ea9df 100644 --- 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 @@ -1,5 +1,5 @@ import { useEffect, useMemo, useState } from 'react' -import { useClusterLogs } from '@qovery/domains/clusters/feature' +import { useClusterLogs } from '../use-cluster-logs/use-cluster-logs' export type StepStatus = 'current' | 'pending' | 'done' export type LifecycleState = 'idle' | 'installing' | 'succeeded' | 'failed' @@ -110,8 +110,7 @@ export function useDeploymentProgress({ break } - const isCreateError = - log.step === 'CreateError' || normalizedMessage.includes('createerror') + const isCreateError = log.step === 'CreateError' || normalizedMessage.includes('createerror') if (isCreateError) { isFailed = true @@ -121,20 +120,28 @@ export function useDeploymentProgress({ 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'), + match: (msg) => msg.includes('Check if cluster has calls to deprecated kubernetes API for version'), // last step }, { index: 1, - match: (msg) => - Boolean(clusterName && providerCode && msg.includes(`Deployment ${providerCode} cluster ${clusterName}`)), + 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('Saved the plan to: tf_plan'), + 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'), + match: (msg) => msg.includes('Preparing Helm files on disk'), // installing Qovery stack }, ] 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 index 4994c5a14d4..7245054a5fb 100644 --- a/libs/domains/clusters/feature/src/lib/utils/cluster-install-tracking.ts +++ b/libs/domains/clusters/feature/src/lib/utils/cluster-install-tracking.ts @@ -1,7 +1,8 @@ +// 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; createdAt: number } +type TrackedInstall = { id: string; name?: string; createdAt: number } const readInstalls = (): TrackedInstall[] => { if (typeof window === 'undefined') return [] @@ -25,10 +26,10 @@ const writeInstalls = (installs: TrackedInstall[]) => { } } -export const trackClusterInstall = (clusterId: string) => { +export const trackClusterInstall = (clusterId: string, clusterName?: string) => { if (!clusterId) return const existing = readInstalls().filter((entry) => entry.id !== clusterId) - existing.push({ id: clusterId, createdAt: Date.now() }) + existing.push({ id: clusterId, name: clusterName, createdAt: Date.now() }) writeInstalls(existing) } @@ -39,3 +40,5 @@ export const clearTrackedClusterInstall = (clusterId: string) => { } 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 145695f529d..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,7 +1,13 @@ +import { ClusterStateEnum } from 'qovery-typescript-axios' import { useClusterMetrics } from '@qovery/domains/cluster-metrics/feature' -import { useCluster, useClusterLogs, useClusterRunningStatus, useClusterStatus } from '@qovery/domains/clusters/feature' +import { + useCluster, + useClusterLogs, + useClusterRunningStatus, + useClusterStatus, + useDeploymentProgress, +} from '@qovery/domains/clusters/feature' import { useProjects } from '@qovery/domains/projects/feature' -import { ClusterStateEnum } from 'qovery-typescript-axios' import { renderWithProviders, screen } from '@qovery/shared/util-tests' import PageOverviewFeature from './page-overview-feature' @@ -10,10 +16,16 @@ jest.mock('@qovery/domains/clusters/feature', () => ({ 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', () => ({ @@ -23,10 +35,10 @@ jest.mock('@qovery/domains/projects/feature', () => ({ describe('PageOverviewFeature', () => { beforeEach(() => { jest.clearAllMocks() - ;(useClusterRunningStatus as jest.Mock).mockReturnValue({ + 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', @@ -36,19 +48,30 @@ describe('PageOverviewFeature', () => { min_running_nodes: 1, }, }) - ;(useClusterMetrics as jest.Mock).mockReturnValue({ + jest.mocked(useClusterMetrics).mockReturnValue({ data: { node_pools: [], nodes: [], }, }) - ;(useClusterLogs as jest.Mock).mockReturnValue({ + jest.mocked(useClusterLogs).mockReturnValue({ data: [], }) - ;(useClusterStatus as jest.Mock).mockReturnValue({ + jest.mocked(useClusterStatus).mockReturnValue({ data: { is_deployed: true, status: ClusterStateEnum.DEPLOYED }, }) - ;(useProjects as jest.Mock).mockReturnValue({ + 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: [], }) }) @@ -59,7 +82,7 @@ describe('PageOverviewFeature', () => { }) it('should render placeholder when running status is unavailable', () => { - ;(useClusterRunningStatus as jest.Mock).mockReturnValue({ + jest.mocked(useClusterRunningStatus).mockReturnValue({ data: 'NotFound', }) renderWithProviders() @@ -67,10 +90,10 @@ describe('PageOverviewFeature', () => { }) it('should display deployment ongoing card when cluster creation is in progress', () => { - ;(useClusterStatus as jest.Mock).mockReturnValue({ + jest.mocked(useClusterStatus).mockReturnValue({ data: { is_deployed: false, status: ClusterStateEnum.DEPLOYING }, }) - ;(useCluster as jest.Mock).mockReturnValue({ + jest.mocked(useCluster).mockReturnValue({ data: { id: 'cluster-123', name: 'test-cluster', 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 b4555cdb5fd..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,6 +1,6 @@ import clsx from 'clsx' -import { useEffect, useRef, useState } from 'react' import { ClusterStateEnum } from 'qovery-typescript-axios' +import { useEffect, useRef, useState } from 'react' import { useParams } from 'react-router-dom' import { ClusterCardNodeUsage, 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 f0f8e647fe0..e51876e1445 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 @@ -8,13 +8,9 @@ import { useCallback, useEffect } from 'react' 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 { useCreateCluster, useDeployCluster, useEditCloudProviderInfo } from '@qovery/domains/clusters/feature' import { trackClusterInstall } from '@qovery/domains/clusters/feature' -import { useNotificationPermissionModal } from '../../../../../../../domains/clusters/feature/src/lib/deployment-progress/cluster-notification-permission-modal' +import { useNotificationPermissionModal } from '@qovery/domains/clusters/feature' import { CLUSTERS_CREATION_EKS_URL, CLUSTERS_CREATION_FEATURES_URL, @@ -137,7 +133,7 @@ export function StepSummaryFeature() { cloud_provider_credentials, }, }) - trackClusterInstall(cluster.id) + trackClusterInstall(cluster.id, cluster.name) await editCloudProviderInfo({ organizationId, clusterId: cluster.id, @@ -172,7 +168,7 @@ export function StepSummaryFeature() { infrastructure_charts_parameters: resourcesData?.infrastructure_charts_parameters, }, }) - trackClusterInstall(cluster.id) + trackClusterInstall(cluster.id, cluster.name) showNotificationPermissionModal(() => navigate(CLUSTERS_URL(organizationId))) } catch (e) { @@ -364,7 +360,7 @@ export function StepSummaryFeature() { organizationId, clusterRequest, }) - trackClusterInstall(cluster.id) + trackClusterInstall(cluster.id, cluster.name) await editCloudProviderInfo({ organizationId, clusterId: cluster.id, diff --git a/libs/shared/devops-copilot/feature/src/lib/devops-copilot-panel/devops-copilot-panel.tsx b/libs/shared/devops-copilot/feature/src/lib/devops-copilot-panel/devops-copilot-panel.tsx index e7e7001e2be..3fdcacade6d 100644 --- a/libs/shared/devops-copilot/feature/src/lib/devops-copilot-panel/devops-copilot-panel.tsx +++ b/libs/shared/devops-copilot/feature/src/lib/devops-copilot-panel/devops-copilot-panel.tsx @@ -185,7 +185,7 @@ export function DevopsCopilotPanel({ onClose, style }: DevopsCopilotPanelProps) threads = [], error: errorThreads, isLoading: isLoadingThreads, - refetchThreads: refetchThreads, + refetchThreads, } = useThreads({ organizationId: context?.organization?.id ?? '', owner: user?.sub ?? '' }) const { thread, setThread } = useThreadState({ @@ -894,7 +894,7 @@ export function DevopsCopilotPanel({ onClose, style }: DevopsCopilotPanelProps)
    setShowPlans((prev) => ({ ...prev, ['temp']: !prev['temp'] }))} + onClick={() => setShowPlans((prev) => ({ ...prev, temp: !prev['temp'] }))} > {loadingText} {plan.filter((p) => p.messageId === 'temp').length > 0 && ( @@ -929,7 +929,7 @@ export function DevopsCopilotPanel({ onClose, style }: DevopsCopilotPanelProps) {plan.filter((p) => p.messageId === 'temp').length > 0 && (
    setShowPlans((prev) => ({ ...prev, ['temp']: !prev['temp'] }))} + onClick={() => setShowPlans((prev) => ({ ...prev, temp: !prev['temp'] }))} >
    Plan steps
    Date: Tue, 2 Dec 2025 18:10:25 +0100 Subject: [PATCH 07/11] test notif --- __tests__/mocks.ts | 29 ++++++ .../use-cluster-install-notifications.ts | 97 +------------------ .../lib/ui/layout-page/layout-page.spec.tsx | 19 +++- 3 files changed, 48 insertions(+), 97 deletions(-) diff --git a/__tests__/mocks.ts b/__tests__/mocks.ts index b4d35c0db33..54df6871618 100644 --- a/__tests__/mocks.ts +++ b/__tests__/mocks.ts @@ -58,3 +58,32 @@ jest.mock('remark-gfm', () => ({ __esModule: true, default: () => () => {}, })) + +// Prevent WebSocket/timer side effects in tests +jest.mock('@qovery/state/util-queries', () => ({ + ...jest.requireActual('@qovery/state/util-queries'), + useReactQueryWsSubscription: () => {}, +})) + +// Prevent notification hook side effects in tests +jest.mock('@qovery/domains/clusters/feature', () => ({ + ...jest.requireActual('@qovery/domains/clusters/feature'), + useClusterInstallNotifications: () => undefined, + useClusterStatuses: () => ({ data: [] }), +})) + +// Prevent layout from issuing axios requests during tests +jest.mock('@qovery/domains/projects/feature', () => ({ + ...jest.requireActual('@qovery/domains/projects/feature'), + useProjects: () => ({ data: [] }), +})) + +jest.mock('@qovery/domains/organizations/feature', () => ({ + ...jest.requireActual('@qovery/domains/organizations/feature'), + useOrganization: () => ({ data: undefined }), +})) + +jest.mock('@qovery/shared/iam/feature', () => ({ + ...jest.requireActual('@qovery/shared/iam/feature'), + useUserRole: () => ({ roles: [], isQoveryAdminUser: false }), +})) diff --git a/libs/domains/clusters/feature/src/lib/hooks/use-cluster-install-notifications/use-cluster-install-notifications.ts b/libs/domains/clusters/feature/src/lib/hooks/use-cluster-install-notifications/use-cluster-install-notifications.ts index 4e5407ed273..9f31c58c2db 100644 --- a/libs/domains/clusters/feature/src/lib/hooks/use-cluster-install-notifications/use-cluster-install-notifications.ts +++ b/libs/domains/clusters/feature/src/lib/hooks/use-cluster-install-notifications/use-cluster-install-notifications.ts @@ -1,10 +1,7 @@ -import { useQueryClient } from '@tanstack/react-query' 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 { QOVERY_WS } from '@qovery/shared/util-node-env' -import { queries, useReactQueryWsSubscription } from '@qovery/state/util-queries' import { isClusterNotificationEnabled, isClusterSoundEnabled, @@ -43,8 +40,6 @@ export function useClusterInstallNotifications({ ) const trackedIdsRef = useRef>(new Set(trackedInstalls.map(({ id }) => id))) const prevStateRef = useRef>(new Map()) - const seenProgressRef = useRef>(new Set()) - const queryClient = useQueryClient() const trackedNames = useMemo(() => { const map = new Map() @@ -94,76 +89,6 @@ export function useClusterInstallNotifications({ } }, []) - // Background-friendly refetch while the tab is hidden so notifications fire even when unfocused - useEffect(() => { - if (typeof document === 'undefined' || typeof window === 'undefined') return - if (!organizationId) return - - const queryKey = queries.clusters.listStatuses({ organizationId }).queryKey - let intervalId: ReturnType | undefined - - const stopInterval = () => { - if (intervalId) { - clearInterval(intervalId) - intervalId = undefined - } - } - - const refetchStatuses = () => { - if (trackedIdsRef.current.size === 0) { - stopInterval() - return - } - void queryClient.invalidateQueries({ queryKey }) - } - - const handleVisibility = () => { - if (document.visibilityState === 'hidden' && trackedIdsRef.current.size > 0) { - refetchStatuses() // kick once on hide - if (!intervalId) { - intervalId = setInterval(refetchStatuses, 15_000) - } - } else { - stopInterval() - } - } - - handleVisibility() - document.addEventListener('visibilitychange', handleVisibility) - - return () => { - stopInterval() - document.removeEventListener('visibilitychange', handleVisibility) - } - }, [organizationId, queryClient, trackedInstalls.length]) - - // WebSocket subscription scoped to tracked clusters to avoid timer throttling when unfocused - useReactQueryWsSubscription({ - url: QOVERY_WS + '/cluster/status', - urlSearchParams: { organization: organizationId }, - enabled: Boolean(organizationId) && trackedInstalls.length > 0, - onMessage(queryClientFromWs, message: ClusterStatus) { - const clusterId = (message as ClusterStatus).cluster_id - if (!clusterId || !trackedIdsRef.current.has(clusterId)) return - - queryClientFromWs.setQueryData( - queries.clusters.listStatuses({ organizationId }).queryKey, - (prev: ClusterStatus[] | undefined) => { - if (!prev) return [message] - let found = false - const next = prev.map((status) => { - if (status.cluster_id === clusterId) { - found = true - return { ...status, ...message } - } - return status - }) - return found ? next : [...next, message] - } - ) - }, - }) - useEffect(() => { if (!isClusterNotificationEnabled()) return if (typeof window === 'undefined') return @@ -178,25 +103,15 @@ export function useClusterInstallNotifications({ const cachedState = getCachedDeploymentProgress(clusterId)?.state const derivedState = cachedState ?? deriveStateFromStatus(status?.status, status?.is_deployed) const prevState = prevStateRef.current.get(clusterId) ?? 'idle' - const hasSeenProgress = seenProgressRef.current.has(clusterId) - - if (derivedState === 'installing') { - seenProgressRef.current.add(clusterId) - } - const transitionedToSuccess = - derivedState === 'succeeded' && prevState !== 'succeeded' && (hasSeenProgress || prevState === 'installing') + 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) => { - const next = prev.filter((install) => install.id !== clusterId) - trackedIdsRef.current = new Set(next.map(({ id }) => id)) - return next - }) + setTrackedInstalls((prev) => prev.filter((install) => install.id !== clusterId)) const clusterName = trackedNames.get(clusterId) ?? clusters.find((cluster) => cluster.id === clusterId)?.name ?? 'Cluster' @@ -219,11 +134,7 @@ export function useClusterInstallNotifications({ notifiedClustersRef.current.add(clusterId) clearTrackedClusterInstall(clusterId) trackedIdsRef.current.delete(clusterId) - setTrackedInstalls((prev) => { - const next = prev.filter((install) => install.id !== clusterId) - trackedIdsRef.current = new Set(next.map(({ id }) => id)) - return next - }) + setTrackedInstalls((prev) => prev.filter((install) => install.id !== clusterId)) const clusterName = trackedNames.get(clusterId) ?? clusters.find((cluster) => cluster.id === clusterId)?.name ?? 'Cluster' @@ -256,7 +167,7 @@ export function useClusterInstallNotifications({ prevStateRef.current.set(clusterId, derivedState) }) - }, [clusterStatuses, clusters, organizationId, projects]) + }, [clusterStatuses, clusters, organizationId, projects, trackedInstalls, trackedNames]) } export default useClusterInstallNotifications 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() }) }) From 963013355b88002afb56da029092d76b3aef849a Mon Sep 17 00:00:00 2001 From: RemiBonnet Date: Wed, 3 Dec 2025 16:12:14 +0100 Subject: [PATCH 08/11] Remove useless changes --- __mocks__/fileMock.js | 1 - __tests__/mocks.ts | 29 ------------------- jest.preset.js | 3 -- .../devops-copilot-panel.tsx | 6 ++-- 4 files changed, 3 insertions(+), 36 deletions(-) delete mode 100644 __mocks__/fileMock.js diff --git a/__mocks__/fileMock.js b/__mocks__/fileMock.js deleted file mode 100644 index 0e56c5b5f76..00000000000 --- a/__mocks__/fileMock.js +++ /dev/null @@ -1 +0,0 @@ -module.exports = 'test-file-stub' diff --git a/__tests__/mocks.ts b/__tests__/mocks.ts index 54df6871618..b4d35c0db33 100644 --- a/__tests__/mocks.ts +++ b/__tests__/mocks.ts @@ -58,32 +58,3 @@ jest.mock('remark-gfm', () => ({ __esModule: true, default: () => () => {}, })) - -// Prevent WebSocket/timer side effects in tests -jest.mock('@qovery/state/util-queries', () => ({ - ...jest.requireActual('@qovery/state/util-queries'), - useReactQueryWsSubscription: () => {}, -})) - -// Prevent notification hook side effects in tests -jest.mock('@qovery/domains/clusters/feature', () => ({ - ...jest.requireActual('@qovery/domains/clusters/feature'), - useClusterInstallNotifications: () => undefined, - useClusterStatuses: () => ({ data: [] }), -})) - -// Prevent layout from issuing axios requests during tests -jest.mock('@qovery/domains/projects/feature', () => ({ - ...jest.requireActual('@qovery/domains/projects/feature'), - useProjects: () => ({ data: [] }), -})) - -jest.mock('@qovery/domains/organizations/feature', () => ({ - ...jest.requireActual('@qovery/domains/organizations/feature'), - useOrganization: () => ({ data: undefined }), -})) - -jest.mock('@qovery/shared/iam/feature', () => ({ - ...jest.requireActual('@qovery/shared/iam/feature'), - useUserRole: () => ({ roles: [], isQoveryAdminUser: false }), -})) diff --git a/jest.preset.js b/jest.preset.js index d3d0901ceb8..09b708d7bd9 100644 --- a/jest.preset.js +++ b/jest.preset.js @@ -1,8 +1,6 @@ const nxPreset = require('@nx/jest/preset').default const path = require('path') -const fileMock = path.join(__dirname, '__mocks__/fileMock.js') - module.exports = { setupFilesAfterEnv: [path.join(__dirname, '__tests__/mocks.ts'), 'jest-canvas-mock'], collectCoverage: true, @@ -15,7 +13,6 @@ module.exports = { ], moduleNameMapper: { '^.+\\.module\\.(css|sass|scss)$': 'identity-obj-proxy', - '\\.(mp3)$': fileMock, }, resetMocks: true, ...nxPreset, diff --git a/libs/shared/devops-copilot/feature/src/lib/devops-copilot-panel/devops-copilot-panel.tsx b/libs/shared/devops-copilot/feature/src/lib/devops-copilot-panel/devops-copilot-panel.tsx index 3fdcacade6d..e7e7001e2be 100644 --- a/libs/shared/devops-copilot/feature/src/lib/devops-copilot-panel/devops-copilot-panel.tsx +++ b/libs/shared/devops-copilot/feature/src/lib/devops-copilot-panel/devops-copilot-panel.tsx @@ -185,7 +185,7 @@ export function DevopsCopilotPanel({ onClose, style }: DevopsCopilotPanelProps) threads = [], error: errorThreads, isLoading: isLoadingThreads, - refetchThreads, + refetchThreads: refetchThreads, } = useThreads({ organizationId: context?.organization?.id ?? '', owner: user?.sub ?? '' }) const { thread, setThread } = useThreadState({ @@ -894,7 +894,7 @@ export function DevopsCopilotPanel({ onClose, style }: DevopsCopilotPanelProps)
    setShowPlans((prev) => ({ ...prev, temp: !prev['temp'] }))} + onClick={() => setShowPlans((prev) => ({ ...prev, ['temp']: !prev['temp'] }))} > {loadingText} {plan.filter((p) => p.messageId === 'temp').length > 0 && ( @@ -929,7 +929,7 @@ export function DevopsCopilotPanel({ onClose, style }: DevopsCopilotPanelProps) {plan.filter((p) => p.messageId === 'temp').length > 0 && (
    setShowPlans((prev) => ({ ...prev, temp: !prev['temp'] }))} + onClick={() => setShowPlans((prev) => ({ ...prev, ['temp']: !prev['temp'] }))} >
    Plan steps
    Date: Thu, 4 Dec 2025 15:41:33 +0100 Subject: [PATCH 09/11] Clean-up cluster install notification modal with right behavior --- libs/domains/clusters/feature/src/index.ts | 6 +- .../cluster-action-toolbar.tsx | 9 +- .../cluster-notification-permission-modal.tsx | 68 ++++----- .../use-cluster-install-notifications.ts | 8 +- .../floating-deployment-progress-card.tsx | 3 - .../step-summary-feature.tsx | 131 ++++++++++-------- 6 files changed, 114 insertions(+), 111 deletions(-) rename libs/domains/clusters/feature/src/lib/{deployment-progress => cluster-deployment-progress/cluster-notification-permission-modal}/cluster-notification-permission-modal.tsx (76%) rename libs/domains/clusters/feature/src/lib/{hooks/use-cluster-install-notifications => cluster-deployment-progress/cluster-notification-permission-modal}/use-cluster-install-notifications.ts (96%) rename libs/domains/clusters/feature/src/lib/{deployment-progress => cluster-deployment-progress}/floating-deployment-progress-card.tsx (99%) diff --git a/libs/domains/clusters/feature/src/index.ts b/libs/domains/clusters/feature/src/index.ts index 7ff8ae120af..fa8a22ca749 100644 --- a/libs/domains/clusters/feature/src/index.ts +++ b/libs/domains/clusters/feature/src/index.ts @@ -21,7 +21,6 @@ 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-cluster-install-notifications/use-cluster-install-notifications' 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' @@ -42,7 +41,8 @@ export * from './lib/hooks/use-update-karpenter-private-fargate/use-update-karpe 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 { FloatingDeploymentProgressCard } from './lib/deployment-progress/floating-deployment-progress-card' -export { useNotificationPermissionModal } from './lib/deployment-progress/cluster-notification-permission-modal' +export * from './lib/cluster-deployment-progress/floating-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/deployment-progress/cluster-notification-permission-modal.tsx b/libs/domains/clusters/feature/src/lib/cluster-deployment-progress/cluster-notification-permission-modal/cluster-notification-permission-modal.tsx similarity index 76% rename from libs/domains/clusters/feature/src/lib/deployment-progress/cluster-notification-permission-modal.tsx rename to libs/domains/clusters/feature/src/lib/cluster-deployment-progress/cluster-notification-permission-modal/cluster-notification-permission-modal.tsx index 9924f64c0e4..1ce02440c1c 100644 --- a/libs/domains/clusters/feature/src/lib/deployment-progress/cluster-notification-permission-modal.tsx +++ b/libs/domains/clusters/feature/src/lib/cluster-deployment-progress/cluster-notification-permission-modal/cluster-notification-permission-modal.tsx @@ -1,5 +1,8 @@ -import { useCallback, useState } from 'react' -import { Button, Icon, InputToggle, useModal } from '@qovery/shared/ui' +import { 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 { SHOW_SELF_MANAGED_GUIDE_KEY } from '../../cluster-action-toolbar/cluster-action-toolbar' const NOTIFICATION_MODAL_SEEN_KEY = 'cluster-notification-permission-modal-v2-seen' const NOTIFICATION_ENABLED_KEY = 'cluster-notification-enabled' @@ -28,22 +31,17 @@ const getStoredBoolean = (key: string) => { return localStorage.getItem(key) === 'true' } -export const getNotificationModalSeen = () => { - if (typeof window === 'undefined') return true - return localStorage.getItem(NOTIFICATION_MODAL_SEEN_KEY) === 'true' -} - -export const setNotificationModalSeen = () => { +const setNotificationModalSeen = () => { if (typeof window === 'undefined') return localStorage.setItem(NOTIFICATION_MODAL_SEEN_KEY, 'true') } -export const setClusterNotificationEnabled = (enabled: boolean) => { +const setClusterNotificationEnabled = (enabled: boolean) => { if (typeof window === 'undefined') return localStorage.setItem(NOTIFICATION_ENABLED_KEY, enabled ? 'true' : 'false') } -export const setClusterSoundEnabled = (enabled: boolean) => { +const setClusterSoundEnabled = (enabled: boolean) => { if (typeof window === 'undefined') return localStorage.setItem(SOUND_ENABLED_KEY, enabled ? 'true' : 'false') } @@ -56,10 +54,19 @@ export const isClusterNotificationEnabled = () => export const isClusterSoundEnabled = () => getStoredBoolean(SOUND_ENABLED_KEY) export interface ClusterNotificationPermissionModalProps { + organizationId: string onClose: () => void + onComplete: () => Promise + isSelfManaged?: boolean } -export function ClusterNotificationPermissionModal({ onClose }: ClusterNotificationPermissionModalProps) { +export function ClusterNotificationPermissionModal({ + organizationId, + onClose, + onComplete, + isSelfManaged = false, +}: ClusterNotificationPermissionModalProps) { + const navigate = useNavigate() const [notificationsEnabled, setNotificationsEnabled] = useState(() => getStoredBoolean(NOTIFICATION_ENABLED_KEY)) const [soundEnabled, setSoundEnabled] = useState(() => getStoredBoolean(SOUND_ENABLED_KEY)) const [isConfirming, setIsConfirming] = useState(false) @@ -71,6 +78,7 @@ export function ClusterNotificationPermissionModal({ onClose }: ClusterNotificat if (!notificationsEnabled && !soundEnabled) { onClose() + await onComplete() return } @@ -88,6 +96,15 @@ export function ClusterNotificationPermissionModal({ onClose }: ClusterNotificat } finally { setIsConfirming(false) onClose() + await onComplete() + if (isSelfManaged) { + navigate({ + pathname: CLUSTERS_URL(organizationId) + CLUSTERS_GENERAL_URL, + search: `?${SHOW_SELF_MANAGED_GUIDE_KEY}`, + }) + } else { + navigate(CLUSTERS_URL(organizationId)) + } } } @@ -108,6 +125,7 @@ export function ClusterNotificationPermissionModal({ onClose }: ClusterNotificat title="Browser notifications" description="Receive a browser notification when the installation completes" className="border-b border-neutral-200 py-4" + forceAlignTop />
    @@ -139,31 +158,4 @@ export function ClusterNotificationPermissionModal({ onClose }: ClusterNotificat ) } -export function useNotificationPermissionModal() { - const { openModal, closeModal } = useModal() - - const showNotificationPermissionModal = useCallback( - (onAfterClose?: () => void, force?: boolean) => { - if (!force && getNotificationModalSeen()) { - onAfterClose?.() - return - } - - openModal({ - content: ( - { - closeModal() - onAfterClose?.() - }} - /> - ), - }) - }, - [closeModal, openModal] - ) - - return { showNotificationPermissionModal } -} - export default ClusterNotificationPermissionModal diff --git a/libs/domains/clusters/feature/src/lib/hooks/use-cluster-install-notifications/use-cluster-install-notifications.ts b/libs/domains/clusters/feature/src/lib/cluster-deployment-progress/cluster-notification-permission-modal/use-cluster-install-notifications.ts similarity index 96% rename from libs/domains/clusters/feature/src/lib/hooks/use-cluster-install-notifications/use-cluster-install-notifications.ts rename to libs/domains/clusters/feature/src/lib/cluster-deployment-progress/cluster-notification-permission-modal/use-cluster-install-notifications.ts index 9f31c58c2db..e59a46943de 100644 --- a/libs/domains/clusters/feature/src/lib/hooks/use-cluster-install-notifications/use-cluster-install-notifications.ts +++ b/libs/domains/clusters/feature/src/lib/cluster-deployment-progress/cluster-notification-permission-modal/use-cluster-install-notifications.ts @@ -3,11 +3,11 @@ 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 { - isClusterNotificationEnabled, - isClusterSoundEnabled, -} from '../../deployment-progress/cluster-notification-permission-modal' + type LifecycleState, + getCachedDeploymentProgress, +} from '../../hooks/use-deployment-progress/use-deployment-progress' import { clearTrackedClusterInstall, getTrackedClusterInstalls } from '../../utils/cluster-install-tracking' -import { type LifecycleState, getCachedDeploymentProgress } from '../use-deployment-progress/use-deployment-progress' +import { isClusterNotificationEnabled, isClusterSoundEnabled } from './cluster-notification-permission-modal' type ClusterStatusWithFlag = ClusterStatus diff --git a/libs/domains/clusters/feature/src/lib/deployment-progress/floating-deployment-progress-card.tsx b/libs/domains/clusters/feature/src/lib/cluster-deployment-progress/floating-deployment-progress-card.tsx similarity index 99% rename from libs/domains/clusters/feature/src/lib/deployment-progress/floating-deployment-progress-card.tsx rename to libs/domains/clusters/feature/src/lib/cluster-deployment-progress/floating-deployment-progress-card.tsx index 57c89a6cc41..f8e45291803 100644 --- a/libs/domains/clusters/feature/src/lib/deployment-progress/floating-deployment-progress-card.tsx +++ b/libs/domains/clusters/feature/src/lib/cluster-deployment-progress/floating-deployment-progress-card.tsx @@ -17,7 +17,6 @@ function ClusterRow({ clusterName, cloudProvider, isSingle, - isFirst, isLast, }: { organizationId: string @@ -25,7 +24,6 @@ function ClusterRow({ clusterName?: string cloudProvider?: string isSingle: boolean - isFirst: boolean isLast: boolean }) { const [expanded, setExpanded] = useState(false) @@ -155,7 +153,6 @@ export function FloatingDeploymentProgressCard({ organizationId, clusters }: Flo clusterName={cluster.name} cloudProvider={cluster.cloud_provider} isSingle={isSingle} - isFirst={idx === 0} isLast={idx === clusters.length - 1} /> ))} 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 e51876e1445..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 @@ -10,17 +10,15 @@ 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 { useNotificationPermissionModal } 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' @@ -87,7 +85,23 @@ export function StepSummaryFeature() { navigate(creationFlowUrl + CLUSTERS_CREATION_EKS_URL) } - const { showNotificationPermissionModal } = useNotificationPermissionModal() + const { openModal, closeModal } = useModal() + + const showNotificationPermissionModal = useCallback( + (isSelfManaged = false, onComplete: () => Promise) => { + openModal({ + content: ( + + ), + }) + }, + [openModal, closeModal, organizationId] + ) const onBack = () => { if (generalData?.installation_type === 'SELF_MANAGED') { @@ -120,31 +134,27 @@ 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, - }, - }) - trackClusterInstall(cluster.id, cluster.name) - await editCloudProviderInfo({ - organizationId, - clusterId: cluster.id, - cloudProviderInfoRequest: cloud_provider_credentials, - }) - showNotificationPermissionModal(() => - 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) } @@ -154,23 +164,24 @@ export function StepSummaryFeature() { // EKS if (generalData.installation_type === 'PARTIALLY_MANAGED') { try { - 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, - }, + 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) }) - trackClusterInstall(cluster.id, cluster.name) - - showNotificationPermissionModal(() => navigate(CLUSTERS_URL(organizationId))) } catch (e) { console.error(e) } @@ -356,21 +367,23 @@ export function StepSummaryFeature() { }) try { - const cluster = await createCluster({ - organizationId, - clusterRequest, - }) - trackClusterInstall(cluster.id, cluster.name) - 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 }) - } - showNotificationPermissionModal(() => navigate(CLUSTERS_URL(organizationId))) + if (withDeploy) { + await deployCluster({ clusterId: cluster.id, organizationId }) + } + }) } catch (e) { console.error(e) } From c2444f8e4545eeda5dd4319f050643bbf51f18d7 Mon Sep 17 00:00:00 2001 From: RemiBonnet Date: Thu, 4 Dec 2025 16:36:32 +0100 Subject: [PATCH 10/11] Clean-up notifications modal --- ...ter-notification-permission-modal.spec.tsx | 88 +++++++++++++ .../cluster-notification-permission-modal.tsx | 67 ++-------- .../use-cluster-install-notifications.ts | 24 +++- libs/shared/util-hooks/src/index.ts | 1 + .../use-notification-preferences.spec.ts | 116 ++++++++++++++++++ .../use-notification-preferences.ts | 72 +++++++++++ 6 files changed, 302 insertions(+), 66 deletions(-) create mode 100644 libs/domains/clusters/feature/src/lib/cluster-deployment-progress/cluster-notification-permission-modal/cluster-notification-permission-modal.spec.tsx create mode 100644 libs/shared/util-hooks/src/lib/use-notification-preferences/use-notification-preferences.spec.ts create mode 100644 libs/shared/util-hooks/src/lib/use-notification-preferences/use-notification-preferences.ts 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 index 1ce02440c1c..4094dbcd41b 100644 --- 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 @@ -2,56 +2,10 @@ import { 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' -const NOTIFICATION_ENABLED_KEY = 'cluster-notification-enabled' -const SOUND_ENABLED_KEY = 'cluster-sound-enabled' - -const isBrowserNotificationSupported = () => typeof window !== 'undefined' && 'Notification' in window - -const requestNotificationPermission = async () => { - if (!isBrowserNotificationSupported()) return - if (Notification.permission === 'default') { - try { - await Notification.requestPermission() - } catch (error) { - console.error(error) - } - } -} - -const requestSoundPermission = async () => { - // Intentionally left blank: no sound is played, we only record the user's choice. - return -} - -const getStoredBoolean = (key: string) => { - if (typeof window === 'undefined') return false - return localStorage.getItem(key) === 'true' -} - -const setNotificationModalSeen = () => { - if (typeof window === 'undefined') return - localStorage.setItem(NOTIFICATION_MODAL_SEEN_KEY, 'true') -} - -const setClusterNotificationEnabled = (enabled: boolean) => { - if (typeof window === 'undefined') return - localStorage.setItem(NOTIFICATION_ENABLED_KEY, enabled ? 'true' : 'false') -} - -const setClusterSoundEnabled = (enabled: boolean) => { - if (typeof window === 'undefined') return - localStorage.setItem(SOUND_ENABLED_KEY, enabled ? 'true' : 'false') -} - -export const isClusterNotificationEnabled = () => - isBrowserNotificationSupported() && - Notification.permission === 'granted' && - getStoredBoolean(NOTIFICATION_ENABLED_KEY) - -export const isClusterSoundEnabled = () => getStoredBoolean(SOUND_ENABLED_KEY) export interface ClusterNotificationPermissionModalProps { organizationId: string @@ -67,14 +21,13 @@ export function ClusterNotificationPermissionModal({ isSelfManaged = false, }: ClusterNotificationPermissionModalProps) { const navigate = useNavigate() - const [notificationsEnabled, setNotificationsEnabled] = useState(() => getStoredBoolean(NOTIFICATION_ENABLED_KEY)) - const [soundEnabled, setSoundEnabled] = useState(() => getStoredBoolean(SOUND_ENABLED_KEY)) + const { notificationsEnabled, setNotificationsEnabled, soundEnabled, setSoundEnabled, requestPermission } = + useNotificationPreferences({ prefix: 'cluster' }) + const [, setModalSeen] = useLocalStorage(NOTIFICATION_MODAL_SEEN_KEY, false) const [isConfirming, setIsConfirming] = useState(false) const handleConfirm = async () => { - setNotificationModalSeen() - setClusterNotificationEnabled(notificationsEnabled) - setClusterSoundEnabled(soundEnabled) + setModalSeen(true) if (!notificationsEnabled && !soundEnabled) { onClose() @@ -84,13 +37,7 @@ export function ClusterNotificationPermissionModal({ setIsConfirming(true) try { - if (notificationsEnabled) { - await requestNotificationPermission() - } - - if (soundEnabled) { - await requestSoundPermission() - } + await requestPermission() } catch (error) { console.error(error) } finally { @@ -144,7 +91,7 @@ export function ClusterNotificationPermissionModal({ variant="plain" color="neutral" onClick={() => { - setNotificationModalSeen() + setModalSeen(true) onClose() }} > 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 index e59a46943de..522a193bdca 100644 --- 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 @@ -2,19 +2,19 @@ import { type Cluster, ClusterStateEnum, type ClusterStatus } from 'qovery-types 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' -import { isClusterNotificationEnabled, isClusterSoundEnabled } from './cluster-notification-permission-modal' type ClusterStatusWithFlag = ClusterStatus const clusterCompletionSoundUrl = '/assets/sound/cluster_completion.mp3' -const playCompletionSound = () => { - if (!isClusterSoundEnabled() || typeof Audio === 'undefined') return +const playCompletionSound = (soundEnabledKey: string) => { + if (!isSoundEnabled(soundEnabledKey) || typeof Audio === 'undefined') return try { const audio = new Audio(clusterCompletionSoundUrl) audio.volume = 0.6 @@ -33,6 +33,9 @@ export function useClusterInstallNotifications({ 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(() => @@ -90,7 +93,7 @@ export function useClusterInstallNotifications({ }, []) useEffect(() => { - if (!isClusterNotificationEnabled()) return + if (!isNotificationEnabled()) return if (typeof window === 'undefined') return if (trackedInstalls.length === 0) return @@ -162,12 +165,21 @@ export function useClusterInstallNotifications({ console.error('Unable to show cluster installation notification', error) } - playCompletionSound() + playCompletionSound(soundEnabledKey) } prevStateRef.current.set(clusterId, derivedState) }) - }, [clusterStatuses, clusters, organizationId, projects, trackedInstalls, trackedNames]) + }, [ + clusterStatuses, + clusters, + organizationId, + projects, + trackedInstalls, + trackedNames, + soundEnabledKey, + isNotificationEnabled, + ]) } export default useClusterInstallNotifications 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, + } +} From e67ad49bfc07190cc399497e792df7fd53bcb67b Mon Sep 17 00:00:00 2001 From: RemiBonnet Date: Fri, 5 Dec 2025 16:53:48 +0100 Subject: [PATCH 11/11] Refactor cluster deployment progress card: replace floating deployment card with a new implementation, add tests, and clean up related components --- libs/domains/clusters/feature/src/index.ts | 2 +- .../cluster-deployment-progress-card.spec.tsx | 138 ++++++++++++++ .../cluster-deployment-progress-card.tsx | 176 ++++++++++++++++++ .../cluster-notification-permission-modal.tsx | 33 ++-- .../floating-deployment-progress-card.tsx | 163 ---------------- .../src/lib/ui/layout-page/layout-page.tsx | 6 +- 6 files changed, 340 insertions(+), 178 deletions(-) create mode 100644 libs/domains/clusters/feature/src/lib/cluster-deployment-progress/cluster-deployment-progress-card/cluster-deployment-progress-card.spec.tsx create mode 100644 libs/domains/clusters/feature/src/lib/cluster-deployment-progress/cluster-deployment-progress-card/cluster-deployment-progress-card.tsx delete mode 100644 libs/domains/clusters/feature/src/lib/cluster-deployment-progress/floating-deployment-progress-card.tsx diff --git a/libs/domains/clusters/feature/src/index.ts b/libs/domains/clusters/feature/src/index.ts index fa8a22ca749..1f508e1390e 100644 --- a/libs/domains/clusters/feature/src/index.ts +++ b/libs/domains/clusters/feature/src/index.ts @@ -41,7 +41,7 @@ export * from './lib/hooks/use-update-karpenter-private-fargate/use-update-karpe 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/floating-deployment-progress-card' +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' 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.tsx b/libs/domains/clusters/feature/src/lib/cluster-deployment-progress/cluster-notification-permission-modal/cluster-notification-permission-modal.tsx index 4094dbcd41b..24fa1f1cbd3 100644 --- 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 @@ -1,4 +1,4 @@ -import { useState } from 'react' +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' @@ -26,6 +26,17 @@ export function ClusterNotificationPermissionModal({ 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) @@ -44,14 +55,7 @@ export function ClusterNotificationPermissionModal({ setIsConfirming(false) onClose() await onComplete() - if (isSelfManaged) { - navigate({ - pathname: CLUSTERS_URL(organizationId) + CLUSTERS_GENERAL_URL, - search: `?${SHOW_SELF_MANAGED_GUIDE_KEY}`, - }) - } else { - navigate(CLUSTERS_URL(organizationId)) - } + navigateToClusters() } } @@ -90,9 +94,16 @@ export function ClusterNotificationPermissionModal({ size="lg" variant="plain" color="neutral" - onClick={() => { + onClick={async () => { setModalSeen(true) - onClose() + try { + await onComplete() + navigateToClusters() + } catch (error) { + console.error(error) + } finally { + onClose() + } }} > Not now diff --git a/libs/domains/clusters/feature/src/lib/cluster-deployment-progress/floating-deployment-progress-card.tsx b/libs/domains/clusters/feature/src/lib/cluster-deployment-progress/floating-deployment-progress-card.tsx deleted file mode 100644 index f8e45291803..00000000000 --- a/libs/domains/clusters/feature/src/lib/cluster-deployment-progress/floating-deployment-progress-card.tsx +++ /dev/null @@ -1,163 +0,0 @@ -import clsx from 'clsx' -import { useState } from 'react' -import { useProjects } from '@qovery/domains/projects/feature' -import { INFRA_LOGS_URL, OVERVIEW_URL } from '@qovery/shared/routes' -import { Icon, Link } from '@qovery/shared/ui' -import { AnimatedGradientText } from '@qovery/shared/ui' -import { useDeploymentProgress } from '../hooks/use-deployment-progress/use-deployment-progress' - -export interface FloatingDeploymentProgressCardProps { - organizationId: string - clusters: { id: string; name?: string; cloud_provider?: string }[] -} - -function ClusterRow({ - organizationId, - clusterId, - clusterName, - cloudProvider, - isSingle, - isLast, -}: { - organizationId: string - clusterId: string - clusterName?: string - cloudProvider?: string - isSingle: boolean - isLast: boolean -}) { - const [expanded, setExpanded] = useState(false) - const { steps, progressValue, currentStepLabel, state } = useDeploymentProgress({ - organizationId, - clusterId, - clusterName, - cloudProvider, - }) - const { data: projects = [] } = useProjects({ organizationId, enabled: !!organizationId }) - const projectTarget = state === 'succeeded' ? projects[0] : undefined - - const rowClasses = isSingle ? '' : clsx(!isLast && 'border-b border-neutral-200') - - const containerClasses = isSingle ? 'rounded-xl' : '' - const isInstalling = state === 'installing' - const isFailed = state === 'failed' - const isSucceeded = state === 'succeeded' - const isDone = isFailed || isSucceeded - const targetLink = isFailed - ? INFRA_LOGS_URL(organizationId, clusterId) - : projectTarget - ? OVERVIEW_URL(organizationId, projectTarget.id) - : undefined - const targetLabel = isFailed ? 'See logs' : projectTarget ? 'Start deploying' : undefined - - return ( -
    -
    -
    - {isSucceeded && } - {isFailed && } - {isInstalling && ( - - )} - {!isSucceeded && !isFailed && !isInstalling && ( - - )} - - {clusterName ?? 'Cluster'} {isFailed ? 'creation failed' : isSucceeded ? 'created' : ''} - -
    -
    - {targetLink && targetLabel && ( - - {targetLabel} - - - )} - {isInstalling && ( - - )} - {!isSucceeded && !isFailed && !isInstalling &&

    Deployment queued

    } -
    -
    - {expanded && !isDone && ( -
    -
      - {steps.map(({ label, status }) => ( -
    • - {status === 'done' && } - {status === 'current' && ( - - )} - {status === 'pending' && } - - {label} - -
    • - ))} -
    -
    - )} -
    - ) -} - -export function FloatingDeploymentProgressCard({ organizationId, clusters }: FloatingDeploymentProgressCardProps) { - if (!clusters.length) return null - const isSingle = clusters.length === 1 - - return ( -
    - {clusters.map((cluster, idx) => ( - - ))} -
    - ) -} - -export default FloatingDeploymentProgressCard 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 67e9e423a43..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 @@ -4,7 +4,7 @@ import { type PropsWithChildren, useEffect, useMemo, useState } from 'react' import { useLocation, useNavigate, useParams } from 'react-router-dom' import { match } from 'ts-pattern' import { - FloatingDeploymentProgressCard, + ClusterDeploymentProgressCard, useActiveDeploymentClusters, useClusterInstallNotifications, useClusterStatuses, @@ -201,8 +201,8 @@ export function LayoutPage(props: PropsWithChildren) { )}
    - {showFloatingDeploymentCard && organizationId && deployingClusters.length > 0 && ( - + {showFloatingDeploymentCard && ( + )}