diff --git a/packages/manager/apps/web-hosting/public/translations/dashboard/Messages_fr_FR.json b/packages/manager/apps/web-hosting/public/translations/dashboard/Messages_fr_FR.json index 09aab0b5064f..583a07e1a77e 100644 --- a/packages/manager/apps/web-hosting/public/translations/dashboard/Messages_fr_FR.json +++ b/packages/manager/apps/web-hosting/public/translations/dashboard/Messages_fr_FR.json @@ -62,7 +62,7 @@ "hosting_multisite_domain_configuration_ssl": "SSL", "hosting_multisite_domain_configuration_country_ip_help": "Activez l'IP du pays pour géolocaliser les parties internationales de votre site afin d'optimiser leur référencement.", "hosting_multisite_domain_configuration_firewall": "Activer le firewall", - "hosting_multisite_domain_configuration_firewall_help": "Activez le firewall pour bénéficier d’une protection renforcée de votre site.", + "hosting_multisite_domain_configuration_firewall_help": "Activez le firewall pour bénéficier d'une protection renforcée de votre site.", "hosting_multisite_domain_configuration_ownlog": "Logs séparés", "hosting_multisite_domain_configuration_ownlog_help": "Activez cette option pour avoir des logs séparés pour votre multisite.", "hosting_add_step2_mode_OVH_domain_name_www_question": "Créer également le sous domaine www.{{t0}}", @@ -90,6 +90,125 @@ "hosting_dashboard_modal_update_headline": "Changer le nom de votre site", "hosting_dashboard_modal_update_description": "Veuillez saisir le nouveau nom que vous souhaitez donner à votre site. Il s’agit d’un nom interne, visible uniquement par vous.", "hosting_dashboard_modal_update_input_label": "Nouveau nom", - "hosting_dashboard_modal_update_success": "Le nom de votre site a été mis à jour avec succès" - + "hosting_dashboard_modal_update_success": "Le nom de votre site a été mis à jour avec succès", + "tab_DOMAINS_configuration_add_failure": "Une erreur est survenue lors de l'ajout du ou des domaines à votre hébergement mutualisé.", + "cdn_shared_title": "Configurer votre CDN {{range}} pour {{domain}}", + "cdn_shared_breadcrumb": "Configuration CDN", + "cdn_shared_info": "Retrouvez les paramètres de l'option Shared CDN. Des réglages standards sont appliqués par défaut pour vous permettre de démarrer simplement et rapidement. Les options avancées CDN Security et CDN Advanced vous donnent accès à davantage de paramètres.", + "cdn_shared_help": "Besoin d'aide pour les statistiques et logs de votre site ?", + "cdn_shared_help_link": "Consultez nos guides en ligne.", + "cdn_shared_state_enable": "Activé", + "cdn_shared_state_disabled": "Désactivé", + "cdn_shared_option_category_performance": "Performances", + "cdn_shared_option_category_cache": "Cache", + "cdn_shared_option_category_security": "Sécurité", + "cdn_shared_option_always_online_title": "Always online", + "cdn_shared_option_always_online_info": "Le CDN continue à répondre aux requêtes, même en cas de panne du serveur d'origine.", + "cdn_shared_option_http_https_title": "HTTP/2", + "cdn_shared_option_http_https_info": "Augmentation de la performance et de la sécurité de votre site en utilisant le protocole HTTP/2.", + "cdn_shared_option_brotli_title": "Brotli", + "cdn_shared_option_brotli_info": "Réduire la taille des données transférées grâce aux algorithmes de dernière génération Brotli.", + "cdn_shared_option_dev_mode_title": "Dev-mode", + "cdn_shared_option_dev_mode_info": "Mode développeur pour forcer le CDN à récupérer systématiquement les valeurs sur le serveur d'origine.", + "cdn_shared_option_advanced_flush_title": "Purge avancée", + "cdn_shared_option_advanced_flush_info": "Personnaliser votre purge en choisissant les éléments du cache à vider: tout le site, un dossier, une URI, une extension de fichiers ou à l'aide d'une expression régulière personnalisée. Accessible directement depuis l'onglet Multisite dans la liste des actions.", + "cdn_shared_option_advanced_flush_info_for_basic_and_security": "Personnaliser votre purge en choisissant les éléments du cache à vider: tout le site, un dossier, une URI, une extension de fichiers ou à l'aide d'une expression régulière personnalisée. La purge avancée est disponible via l'option CDN Advanced. Souhaitez-vous changer d'offre ?", + "cdn_shared_option_query_string_title": "Query String", + "cdn_shared_option_query_string_info": "Gérer la mise en cache de contenu basée sur les paramètres de la requête URL, aussi appelée \"Query String\". En fonction de votre configuration, le CDN distribuera la ressource suivante:", + "cdn_shared_option_query_string_sort_ignored": "Désactivé = La ressource est mise en cache en triant ses paramètres.", + "cdn_shared_option_query_string_sort_true": "Activé - Trier les paramètres = La ressource est mise en cache en triant ses paramètres.", + "cdn_shared_option_query_string_sort_false": "Activé - Ignorer les paramètres = La ressource est mise en cache sans aucun paramètre", + "cdn_shared_option_query_string_list_sorted": "Trier les paramètres", + "cdn_shared_option_query_string_list_ignored": "Ignorer les paramètres", + "cdn_shared_option_prewarm_title": "Prewarm", + "cdn_shared_option_prewarm_info": "Forcer la mise en cache permanente de vos ressources primordiales. Le CDN anticipe et rafraîchit automatiquement le cache, sans attendre de requête de l'utilisateur.", + "cdn_shared_option_prewarm_quota": "Dernier calcul du quota", + "cdn_shared_option_prewarm_btn_edit_urls": "Editer la liste des URL", + "cdn_shared_option_http_geolocation_title": "Header HTTP de géolocalisation", + "cdn_shared_option_http_geolocation_info": "Connaître le pays du visiteur pour personnaliser l'expérience proposée. Le code pays est ajouté automatiquement dans le header de chaque requête pour être manipulé par votre serveur d'origine.", + "cdn_shared_option_prefetch_title": "Prefetch", + "cdn_shared_option_prefetch_info": "Anticiper le chargement de la ressource suivante. Précharger la ressource automatiquement dans le cache grâce au header Link. Celui-ci doit se trouver dans le header de chaque page du site web requérant du Prefetch.", + "cdn_shared_option_prefetch_info_link": "Consulter le guide", + "cdn_shared_option_mobile_redirect_title": "Mobile redirect", + "cdn_shared_option_mobile_redirect_info": "Rediriger automatiquement les visiteurs \"Mobile\" vers un site web optimisé. Au choix : rediriger systématiquement vers la racine d'un autre site web, ou conserver l'URL en ne remplaçant que le domaine (ou le sous-domaine).", + "cdn_shared_option_mobile_redirect_strategy_still": "Redirige vers une URL fixe", + "cdn_shared_option_mobile_redirect_strategy_keep": "Redirige et conserve l'URL", + "cdn_shared_option_mobile_redirect_url_placeholder": "https://www.monsiteweb.com", + "cdn_shared_option_cache_rule_title": "Cache rule", + "cdn_shared_option_cache_rule_btn_add_rule": "Ajouter une règle", + "cdn_shared_option_cache_rule_add_rule_max_rules": "L'offre {{range}} vous permet d'ajouter jusqu'à {{maxItems}} règles maximum.", + "cdn_shared_option_cache_rule_table_header_order_by": "Classement", + "cdn_shared_option_cache_rule_table_header_rule_name": "Nom de la règle", + "cdn_shared_option_cache_rule_table_header_resource": "Ressource", + "cdn_shared_option_cache_rule_table_header_options": "Options", + "cdn_shared_option_cache_rule_table_time_to_live": "Durée de vie", + "cdn_shared_option_cache_rule_table_items_type_extension": "Extension", + "cdn_shared_option_cache_rule_table_items_type_folder": "Dossier", + "cdn_shared_option_cache_rule_table_items_type_uri": "URI", + "cdn_shared_option_cache_rule_table_items_type_regex": "Expression régulière", + "cdn_shared_option_cache_rule_table_items_option_set_rule": "Modifier la règle", + "cdn_shared_option_cache_rule_table_items_option_delete_rule": "Supprimer la règle", + "cdn_shared_option_cache_rule_btn_validate": "Appliquer la configuration", + "cdn_shared_option_cors_description": "Spécifier les domaines extérieurs qui seront autorisés à accéder à vos ressources web", + "cdn_shared_option_cors_edit": "Editer la liste des ressources externes", + "cdn_shared_option_https_redirect_description": "Protégez la globalité du trafic de votre site web en le redirigeant vers le protocole HTTPS de façon temporaire ou permanente.", + "cdn_shared_option_https_redirect_301": "Redirection permanente (301)", + "cdn_shared_option_https_redirect_302": "Redirection temporaire (302)", + "cdn_shared_option_hsts_description": "Imposez l'accès à votre site web en HTTPS uniquement. Votre solution est ainsi sécurisée contre les attaques par rétrogradation.", + "cdn_shared_option_hsts_max_age": "Âge maximum.", + "cdn_shared_option_hsts_max_age_seconds": "Secondes", + "cdn_shared_option_hsts_max_age_days": "Jours", + "cdn_shared_option_hsts_max_age_months": "Mois", + "cdn_ssl_required_warning": "Attention l'option que vous avez activée nécessite que le SSL soit actif sur le sous-domaine. Veuillez vérifier la configuration du SSL sur le sous-domaine.", + "cdn_shared_option_mixed_content_description": "Forcez le chargement de l'intégralité du contenu de vos pages web de manière sécurisée, participant ainsi à une expérience utilisateur optimale. Toutes les ressources de votre site, internes comme externes doivent être disponibles en HTTPS.", + "cdn_shared_option_waf_description": "Protégez votre site à l'aide de notre pare-feu, des attaques frauduleuses telles que l'injection, les requêtes illégitimes ou le vol de données. Protégez-vous des principales failles connues du web en filtrant les requêtes et paquets transmis (la liste de failles est administrée et régulièrement mise à jour par OVHcloud).", + "cdn_shared_change_offer_modal_info": "La purge avancée est disponible via l'option CDN Advanced. Souhaitez-vous changer d'offre ?", + "cdn_shared_banner_success": "La configuration de votre CDN a bien été prise en compte et sera appliquée sur votre nom de domaine.", + "cdn_shared_modal_add_rule_title": "Créer une nouvelle règle", + "cdn_shared_modal_set_rule_title": "Modifier la règle {{ruleName}}", + "cdn_shared_modal_add_rule_info": "Créer une nouvelle règle de cache pour cette configuration. Chaque règle de cache permet de définir une fréquence de rafraîchissement pour un sous-ensemble de votre site.", + "cdn_shared_modal_add_rule_resource_info": "Pour l'offre CDN Basic et Security, il est uniquement possible de saisir une extension de fichier.", + "cdn_shared_modal_add_rule_field_resource_type": "Type de ressource", + "cdn_shared_modal_add_rule_field_resource_extension": "Extension", + "cdn_shared_modal_add_rule_field_resource_extension_info": "Veuillez saisir une extension de fichier valide sans mettre de point, par exemple : css", + "cdn_shared_modal_add_rule_field_resource_folder": "Dossier", + "cdn_shared_modal_add_rule_field_resource_folder_info": "Veuillez saisir un chemin valide pour l'un des dossiers présents dans le répertoire racine de votre site web, par exemple : /css/", + "cdn_shared_modal_add_rule_field_resource_regex": "Expression régulière personnalisée", + "cdn_shared_modal_add_rule_field_resource_regex_info": "Veuillez saisir une expression régulière valide, par exemple: .*.css", + "cdn_shared_modal_add_rule_field_resource_uri": "URI", + "cdn_shared_modal_add_rule_field_resource_uri_info": "Veuillez saisir une URI valide pour votre site web, par exemple: /product/howto.pdf", + "cdn_shared_modal_add_rule_field_resource": "Ressource", + "cdn_shared_modal_field_uri_error_required": "Veuillez compléter ce champ", + "cdn_shared_modal_field_uri_error_pattern_extension": "Seules les extensions sont autorisées", + "cdn_shared_modal_field_uri_error_duplicate": "URI déja existante", + "cdn_shared_modal_add_rule_field_rule_name": "Nom de la règle", + "cdn_shared_modal_field_rule_name_error_required": "Veuillez compléter ce champ", + "cdn_shared_modal_field_rule_name_error_pattern": "Doit uniquement contenir des nombres, des lettres non accentuées, underscores et tiret.", + "cdn_shared_modal_field_rule_name_error_duplicate_name": "Nom déja existant", + "cdn_shared_modal_add_rule_field_time_to_live": "Durée de vie", + "cdn_shared_modal_add_rule_field_time_to_live_info": "La durée de vie permet d'indiquer à quelle fréquence vous souhaitez que le CDN rafraîchisse votre contenu dans son cache. Cette durée peut s'exprimer en plusieurs unités de temps grâce au menu déroulant.", + "cdn_shared_modal_add_rule_field_time_to_live_unit": "Valeur", + "cdn_shared_modal_add_rule_field_time_to_live_unit_days": "Jour(s)", + "cdn_shared_modal_add_rule_field_time_to_live_unit_hours": "Heure(s)", + "cdn_shared_modal_add_rule_field_time_to_live_unit_minutes": "Minute(s)", + "cdn_shared_modal_add_rule_field_order_by": "Classement", + "cdn_shared_modal_add_rule_field_order_by_info": "Vos règles de cache sont exécutées dans l'ordre de leur classement. En attribuant le chiffre 1, votre règle sera exécutée en premier et ainsi de suite.", + "cdn_shared_modal_add_rule_btn_validate_rule": "Créer la règle", + "cdn_shared_modal_add_rule_btn_set_rule": "Modifier la règle", + "cdn_shared_change_edit_urls_modal_info": "Définissez la liste des URLs à rafraichir systématiquement dans le cache.", + "cdn_shared_change_edit_urls_modal_protocol_label": "Protocole", + "cdn_shared_change_edit_urls_modal_domain_label": "Nom de domaine", + "cdn_shared_change_edit_urls_modal_resource_label": "Chemin de la ressource", + "cdn_shared_change_edit_urls_modal_propdown_protocol_HTTP": "HTTP", + "cdn_shared_change_edit_urls_modal_propdown_protocol_HTTPS": "HTTPS", + "cdn_shared_change_edit_urls_modal_url_to_preload_label": "Liste des URL à précharger", + "cdn_shared_change_edit_urls_modal_url_to_preload_info": "Vous avez ajouté {{numberOfUrls}} URL. ({{maxUrls}} max)", + "cdn_shared_cors_description": "Les domaines extérieurs que vous spécifierez ici seront autorisés à accéder à vos ressources web", + "cdn_shared_cors_add_domain": "Ajouter un domaine", + "cdn_shared_cors_domain_list": "Liste des domaines", + "cdn_shared_modal_confirm_title": "Appliquer la configuration de votre CDN", + "cdn_shared_modal_confirm_info": "Cliquez sur « Valider » pour appliquer les changements effectués sur votre configuration. En cliquant sur « Annuler », toutes vos dernières modifications seront perdues.", + "cdn_shared_modal_confirm_perf": "Options de perf", + "cdn_shared_modal_confirm_cache": "Options de cache", + "cdn_shared_modal_confirm_security": "Options de securité" } diff --git a/packages/manager/apps/web-hosting/src/constants.ts b/packages/manager/apps/web-hosting/src/constants.ts index 5cec3c8060f4..bed024e585de 100644 --- a/packages/manager/apps/web-hosting/src/constants.ts +++ b/packages/manager/apps/web-hosting/src/constants.ts @@ -68,3 +68,40 @@ export const LOCAL_SEO_ORDER_OPTIONS_SERVICE = { export const LOCAL_SEO_INTERFACE = 'https://localseo.hosting.ovh.net/{lang}/app/ovh?access_token={token}'; +export const SHARED_CDN_OPTIONS = { + PREFETCH: { + LINKS: { + DEFAULT: + 'https://docs.ovh.com/gb/en/hosting/guide_to_using_the_geocache_accelerator_on_a_web_hosting_package/', + ASIA: 'https://docs.ovh.com/asia/en/hosting/guide_to_using_the_geocache_accelerator_on_a_web_hosting_package/', + AU: 'https://docs.ovh.com/au/en/hosting/guide_to_using_the_geocache_accelerator_on_a_web_hosting_package/', + CA: 'https://docs.ovh.com/ca/en/hosting/guide_to_using_the_geocache_accelerator_on_a_web_hosting_package/', + DE: 'https://docs.ovh.com/de/hosting/verwendung_des_geocache_boosters_auf_einem_webhosting/', + ES: 'https://docs.ovh.com/es/hosting/guia_de_uso_del_acelerador_geocache_en_un_alojamiento_web/', + FR: 'https://docs.ovh.com/fr/hosting/accelerer-mon-site-web-en-utilisant-le-cdn/', + GB: 'https://docs.ovh.com/gb/en/hosting/guide_to_using_the_geocache_accelerator_on_a_web_hosting_package/', + IE: 'https://docs.ovh.com/ie/en/hosting/guide_to_using_the_geocache_accelerator_on_a_web_hosting_package/', + IT: 'https://docs.ovh.com/it/hosting/guida_allutilizzo_dellacceleratore_geocache_su_un_hosting_web/', + PL: 'https://docs.ovh.com/pl/hosting/przewodnik_dotyczacy_uslugi_geocache_na_hostingu_www/', + PT: 'https://docs.ovh.com/pt/hosting/guia_de_utilizacao_do_acelerador_geocache_num_alojamento_web/', + QC: 'https://docs.ovh.com/ca/fr/hosting/accelerer-mon-site-web-en-utilisant-le-cdn/', + SG: 'https://docs.ovh.com/sg/en/hosting/guide_to_using_the_geocache_accelerator_on_a_web_hosting_package/', + WE: 'https://docs.ovh.com/us/en/hosting/guide_to_using_the_geocache_accelerator_on_a_web_hosting_package/', + WS: 'https://docs.ovh.com/ca/en/hosting/guide_to_using_the_geocache_accelerator_on_a_web_hosting_package/', + IN: 'https://docs.ovh.com/asia/en/hosting/guide_to_using_the_geocache_accelerator_on_a_web_hosting_package/', + }, + }, + HSTS: { + FIXED_NUMBER: 2, + }, + QUERY_STRING: { + SORT_PARAMS: { SORT: 'sorted', IGNORED: 'ignored' }, + }, + MOBILE_REDIRECT: { + STILL_URL: 'still', + KEEP_URL: 'keep', + }, +}; + +export const CDN_ADVANCED = 'cdn-advanced'; +export const MAX_URL_ENTRIES = 100; diff --git a/packages/manager/apps/web-hosting/src/data/__mocks__/cdn.ts b/packages/manager/apps/web-hosting/src/data/__mocks__/cdn.ts index d785db720a97..add68e3e6b9c 100644 --- a/packages/manager/apps/web-hosting/src/data/__mocks__/cdn.ts +++ b/packages/manager/apps/web-hosting/src/data/__mocks__/cdn.ts @@ -1,4 +1,4 @@ -import { CDN_TYPE, CDN_VERSION, CdnStatus } from '../types/product/cdn'; +import { CDN_TYPE, CDN_VERSION, CdnOption, CdnOptionType, CdnStatus } from '../types/product/cdn'; export const cdnPropertiesMock = { domain: 'abcdef.cluster030.hosting.ovh.net', @@ -8,3 +8,31 @@ export const cdnPropertiesMock = { type: CDN_TYPE.SECURITY, version: CDN_VERSION.CDN_HOSTING, }; + +export const serviceNameCdnMock = { + domain: 'qjfnqci.cluster100.hosting.ovh.net', + free: true, + status: 'created', + taskId: 473761370, + type: 'cdn-basic', + version: 'cdn-hosting', +}; + +export const cdnOptionMock: CdnOption[] = [ + { + name: 'devmode', + type: CdnOptionType.DEVMODE, + config: null, + pattern: null, + enabled: true, + extra: null, + }, + { + name: 'brotli', + type: CdnOptionType.BROTLI, + config: null, + pattern: null, + enabled: true, + extra: null, + }, +]; diff --git a/packages/manager/apps/web-hosting/src/data/api/cdn.ts b/packages/manager/apps/web-hosting/src/data/api/cdn.ts index a07a14377a96..7aa18826936d 100644 --- a/packages/manager/apps/web-hosting/src/data/api/cdn.ts +++ b/packages/manager/apps/web-hosting/src/data/api/cdn.ts @@ -4,8 +4,12 @@ import { CdnAvailableOption, CdnDomain, CdnDomainOption, + CdnOption, CdnProperties, PurgeCDN, + ServiceNameCdn, + TCdnOption, + TCdnOptions, } from '../types/product/cdn'; export const getCDNProperties = async (serviceName: string): Promise => { @@ -94,3 +98,77 @@ export const flushCDNDomainCache = async ( return data; }; + +export const getServiceNameCdn = async (serviceName: string): Promise => { + const { data } = await v6.get(`/hosting/web/${serviceName}/cdn`); + return data; +}; + +export const getCdnOption = async (serviceName: string, domain: string): Promise => { + const { data } = await v6.get( + `/hosting/web/${serviceName}/cdn/domain/${domain}/option`, + ); + return data; +}; + +export const createCdnOption = async ({ + serviceName, + domain, + cdnOption, +}: TCdnOption): Promise => { + const { data } = await v6.post( + `/hosting/web/${serviceName}/cdn/domain/${domain}/option`, + { + ...cdnOption, + }, + ); + return data; +}; + +export const updateCdnOption = async ({ + serviceName, + domain, + option, + cdnOption, +}: TCdnOption): Promise => { + const { data } = await v6.put( + `/hosting/web/${serviceName}/cdn/domain/${domain}/option/${option}`, + { + ...cdnOption, + }, + ); + return data; +}; + +export const updateCdnOptions = async ({ + serviceName, + domain, + cdnOptions, +}: TCdnOptions): Promise[]> => { + const promises = cdnOptions.map((item) => { + const { name, ...cdnOption } = item; + return updateCdnOption({ + serviceName, + domain, + option: name, + cdnOption, + }); + }); + + const results = await Promise.allSettled(promises); + const failed = results.find((r): r is PromiseRejectedResult => r.status === 'rejected'); + + if (failed) { + throw failed.reason ?? new Error('Domain certificate creation failed'); + } + + return results; +}; + +export const deleteCdnOption = async ({ + serviceName, + domain, + option, +}: TCdnOption): Promise => { + await v6.delete(`/hosting/web/${serviceName}/cdn/domain/${domain}/option/${option}`); +}; diff --git a/packages/manager/apps/web-hosting/src/data/hooks/cdn/useCdn.spec.ts b/packages/manager/apps/web-hosting/src/data/hooks/cdn/useCdn.spec.ts new file mode 100644 index 000000000000..96823ab35d10 --- /dev/null +++ b/packages/manager/apps/web-hosting/src/data/hooks/cdn/useCdn.spec.ts @@ -0,0 +1,159 @@ +import '@testing-library/jest-dom'; +import { act, renderHook, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { cdnOptionMock, serviceNameCdnMock } from '@/data/__mocks__/cdn'; +import { CdnOptionType, PatternType } from '@/data/types/product/cdn'; +import { wrapper } from '@/utils/test.provider'; + +import { + useCreateCdnOption, + useDeleteCdnOption, + useGetCdnOption, + useGetServiceNameCdn, + useUpdateCdnOption, +} from './useCdn'; + +const { mockPost, mockPut, mockDelete } = vi.hoisted(() => ({ + mockPost: vi.fn().mockResolvedValue({ data: {} }), + mockPut: vi.fn().mockResolvedValue({ data: {} }), + mockDelete: vi.fn().mockResolvedValue({ data: {} }), +})); + +vi.mock('@ovh-ux/manager-core-api', () => ({ + v6: { + post: mockPost, + put: mockPut, + delete: mockDelete, + }, +})); + +const onSuccess = vi.fn(); +const onError = vi.fn(); + +it('useGetServiceNameCdn: should return webhosting cdn ', async () => { + const { result } = renderHook(() => useGetServiceNameCdn('serviceName'), { + wrapper, + }); + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + expect(result.current.data).toEqual(serviceNameCdnMock); +}); + +it('useGetCdnOption: should return webhosting cdn options ', async () => { + const { result } = renderHook(() => useGetCdnOption('serviceName', 'domain'), { + wrapper, + }); + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + expect(result.current.data).toEqual(cdnOptionMock); +}); + +describe('useCreateCdnOption', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('create cdn cache rule option', async () => { + const { result } = renderHook( + () => useCreateCdnOption('serviceName', 'domainId', onSuccess, onError), + { + wrapper, + }, + ); + + act(() => + result.current.mutate({ + cdnOption: { + config: { + patternType: PatternType.EXTENSION, + priority: 1, + ttl: 180, + }, + enabled: false, + pattern: 'jpg', + type: CdnOptionType.CACHE_RULE, + name: 'test', + }, + }), + ); + + await waitFor(() => { + expect(mockPost).toHaveBeenCalledWith('/hosting/web/serviceName/cdn/domain/domainId/option', { + type: CdnOptionType.CACHE_RULE, + name: 'test', + pattern: 'jpg', + enabled: false, + config: { ttl: 180, priority: 1, patternType: PatternType.EXTENSION }, + }); + + expect(onSuccess).toHaveBeenCalled(); + }); + }); +}); + +describe('useUpdateCdnOption', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('update cdn cache rule option', async () => { + const { result } = renderHook( + () => useUpdateCdnOption('serviceName', 'domainId', onSuccess, onError), + { + wrapper, + }, + ); + + act(() => + result.current.mutate({ + option: 'test', + cdnOption: { + config: { + patternType: PatternType.EXTENSION, + priority: 1, + ttl: 180, + }, + enabled: false, + pattern: 'jpg', + type: CdnOptionType.CACHE_RULE, + name: 'test', + }, + }), + ); + + await waitFor(() => { + expect(mockPut).toHaveBeenCalledWith( + '/hosting/web/serviceName/cdn/domain/domainId/option/test', + { + enabled: false, + type: CdnOptionType.CACHE_RULE, + config: { ttl: 180, priority: 1, patternType: PatternType.EXTENSION }, + pattern: 'jpg', + name: 'test', + }, + ); + + expect(onSuccess).toHaveBeenCalled(); + }); + }); +}); + +describe('useDeleteCdnOption', () => { + it('should call delete with the correct URL', async () => { + const { result } = renderHook( + () => useDeleteCdnOption('serviceName', 'domainId', onSuccess, onError), + { + wrapper, + }, + ); + act(() => result.current.mutate('optionId')); + await waitFor(() => { + expect(mockDelete).toHaveBeenCalledWith( + `/hosting/web/serviceName/cdn/domain/domainId/option/optionId`, + ); + }); + }); +}); diff --git a/packages/manager/apps/web-hosting/src/data/hooks/cdn/useCdn.ts b/packages/manager/apps/web-hosting/src/data/hooks/cdn/useCdn.ts index 1fa8a683ed4b..9e0d75179bed 100644 --- a/packages/manager/apps/web-hosting/src/data/hooks/cdn/useCdn.ts +++ b/packages/manager/apps/web-hosting/src/data/hooks/cdn/useCdn.ts @@ -1,13 +1,23 @@ -import { useQuery } from '@tanstack/react-query'; +import { useMutation, useQuery } from '@tanstack/react-query'; + +import { ApiError } from '@ovh-ux/manager-core-api'; //@TO DO comments for nexts dev import { + // getSharedCDNDomainDetails, + // getSharedCDNDomains, + createCdnOption, + deleteCdnOption, // getCDNDomainOptionDetails, //getCDNDomainsOptions, getCDNProperties, - // getSharedCDNDomainDetails, - // getSharedCDNDomains, + getCdnOption, + getServiceNameCdn, + updateCdnOption, + updateCdnOptions, } from '@/data/api/cdn'; +import { TCdnOption, TCdnOptions } from '@/data/types/product/cdn'; +import queryClient from '@/utils/queryClient'; const baseCdnQueryKey = (serviceName: string) => ['hosting', 'web', serviceName, 'cdn']; @@ -53,3 +63,133 @@ export const useGetCDNDomainOptionDetails = (serviceName: string, domain: string enabled: Boolean(serviceName), }); */ + +export const useGetServiceNameCdn = (serviceName: string) => + useQuery({ + queryKey: ['hosting', 'web', serviceName, 'cdn'], + queryFn: () => getServiceNameCdn(serviceName), + enabled: Boolean(serviceName), + }); + +export const useGetCdnOption = (serviceName: string, domain: string) => + useQuery({ + queryKey: ['hosting', 'web', serviceName, 'cdn', 'domain', domain, 'option'], + queryFn: () => getCdnOption(serviceName, domain), + enabled: Boolean(serviceName && domain), + }); + +export const useCreateCdnOption = ( + serviceName: string, + domain: string, + onSuccess?: () => void, + onError?: (err: Error) => void, +) => { + const mutation = useMutation({ + mutationFn: (payload) => + (async () => { + await createCdnOption({ + serviceName, + domain, + ...payload, + }); + })(), + onSuccess: () => { + onSuccess?.(); + void queryClient.invalidateQueries({ + queryKey: ['hosting', 'web', serviceName, 'cdn', 'domain', domain, 'option'], + }); + }, + onError, + }); + + return { + createCdnOption: mutation.mutate, + createCdnOptionAsync: mutation.mutateAsync, + ...mutation, + }; +}; + +export const useUpdateCdnOption = ( + serviceName: string, + domain: string, + onSuccess?: () => void, + onError?: (err: Error) => void, +) => { + const mutation = useMutation({ + mutationFn: (payload) => + (async () => { + await updateCdnOption({ + serviceName, + domain, + ...payload, + }); + })(), + onSuccess: () => { + onSuccess?.(); + void queryClient.invalidateQueries({ + queryKey: ['hosting', 'web', serviceName, 'cdn', 'domain', domain, 'option'], + }); + }, + onError, + }); + + return { + updateCdnOption: mutation.mutate, + updateCdnOptionAsync: mutation.mutateAsync, + ...mutation, + }; +}; + +export const useUpdateCdnOptions = ( + serviceName: string, + domain: string, + onSuccess?: () => void, + onError?: (err: Error) => void, +) => { + const mutation = useMutation({ + mutationFn: (payload) => + (async () => { + await updateCdnOptions({ + serviceName, + domain, + ...payload, + }); + })(), + onSuccess: () => { + onSuccess?.(); + void queryClient.invalidateQueries({ + queryKey: ['hosting', 'web', serviceName, 'cdn', 'domain', domain, 'option'], + }); + }, + onError, + }); + + return { + updateCdnOptions: mutation.mutate, + updateCdnOptionsAsync: mutation.mutateAsync, + ...mutation, + }; +}; + +export const useDeleteCdnOption = ( + serviceName: string, + domain: string, + onSuccess?: () => void, + onError?: (error: ApiError) => void, +) => { + const mutation = useMutation({ + mutationFn: (option: string) => deleteCdnOption({ serviceName, domain, option }), + onSuccess: () => { + onSuccess?.(); + void queryClient.invalidateQueries({ + queryKey: ['hosting', 'web', serviceName, 'cdn', 'domain', domain, 'option'], + }); + }, + onError, + }); + + return { + deleteCdnOption: mutation.mutate, + ...mutation, + }; +}; diff --git a/packages/manager/apps/web-hosting/src/data/types/product/cdn.ts b/packages/manager/apps/web-hosting/src/data/types/product/cdn.ts index 4d37e10e87f9..fc3ff57648e8 100644 --- a/packages/manager/apps/web-hosting/src/data/types/product/cdn.ts +++ b/packages/manager/apps/web-hosting/src/data/types/product/cdn.ts @@ -183,3 +183,82 @@ export interface PurgeCDN { todoDate: Date; updatedDate: Date; } + +export type ServiceNameCdn = { + domain: string; + free: boolean; + status: CdnStatus; + taskId: number; + type: string; + version: string; +}; + +export enum PatternType { + EXTENSION = 'extension', + FOLDER = 'folder', + REGEX = 'regex', + URI = 'uri', +} + +export enum CdnQueryParameters { + IGNORED = 'ignored', + SORTED = 'sorted', +} + +export type CdnOption = { + config: { + destination?: string; + followUri?: boolean; + origins?: string; + patternType: PatternType; + priority: number; + queryParameters?: CdnQueryParameters; + resources?: string[]; + statusCode?: number; + ttl: number; + }; + enabled: boolean; + extra?: { + quota: number; + usage: number; + }; + name?: string; + pattern: string; + type: CdnOptionType; +}; + +export type CdnFormValues = { + brotli: boolean; + geoHeaders: boolean; + prefetch: boolean; + mobileRedirect: boolean; + devmode: boolean; + querystring: boolean; + prewarm: boolean; + cors: boolean; + httpsRedirect: boolean; + hsts: boolean; + mixedContent: boolean; + waf: boolean; + hstsAge: number; + hstUnit: number; + mobileRedirectType: string; + mobileRedirectUrl: string; + corsResources?: string[]; + premwarmResources?: string[]; + querytringParam: CdnQueryParameters; + httpsRedirectCode: number; +}; + +export type TCdnOption = { + serviceName?: string; + domain?: string; + option?: string; + cdnOption?: CdnOption; +}; + +export type TCdnOptions = { + serviceName?: string; + domain?: string; + cdnOptions?: CdnOption[]; +}; diff --git a/packages/manager/apps/web-hosting/src/pages/dashboard/multisite/cdn/AdvancedFlushCdn.modal.spec.tsx b/packages/manager/apps/web-hosting/src/pages/dashboard/multisite/cdn/AdvancedFlushCdn.modal.spec.tsx new file mode 100644 index 000000000000..7f0fb39074cd --- /dev/null +++ b/packages/manager/apps/web-hosting/src/pages/dashboard/multisite/cdn/AdvancedFlushCdn.modal.spec.tsx @@ -0,0 +1,36 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { navigate } from '@/utils/test.setup'; + +import AdvancedFlushCdnModal from './AdvancedFlushCdn.modal'; + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ t: (k: string) => k }), + Trans: ({ i18nKey }: { i18nKey: string }) => i18nKey, +})); + +describe('AdvancedFlushCdnModal', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should close modal on cancel', () => { + render(); + + const cancelBtn = screen.getByTestId('secondary-button'); + expect(cancelBtn).not.toBeNull(); + fireEvent.click(cancelBtn); + + expect(navigate).toHaveBeenCalled(); + }); + + it('validate and open upgrade cdn page and close modal', () => { + render(); + + const primaryBtn = screen.getByTestId('primary-button'); + fireEvent.click(primaryBtn); + + expect(window.location.href).toBe('http://localhost:3000/'); + }); +}); diff --git a/packages/manager/apps/web-hosting/src/pages/dashboard/multisite/cdn/AdvancedFlushCdn.modal.tsx b/packages/manager/apps/web-hosting/src/pages/dashboard/multisite/cdn/AdvancedFlushCdn.modal.tsx new file mode 100644 index 000000000000..81d5f90f90ba --- /dev/null +++ b/packages/manager/apps/web-hosting/src/pages/dashboard/multisite/cdn/AdvancedFlushCdn.modal.tsx @@ -0,0 +1,35 @@ +import { useNavigate, useParams } from 'react-router-dom'; + +import { useTranslation } from 'react-i18next'; + +import { OdsText } from '@ovhcloud/ods-components/react'; + +import { NAMESPACES } from '@ovh-ux/manager-common-translations'; +import { Modal } from '@ovh-ux/manager-react-components'; + +import { useHostingUrl } from '@/hooks'; + +export default function AdvancedFlushCdnModal() { + const { serviceName } = useParams(); + const { t } = useTranslation(['dashboard', NAMESPACES.ACTIONS]); + const navigate = useNavigate(); + const upgradeCdnlUrl = useHostingUrl(serviceName, 'cdn/upgrade'); + + const onClose = () => { + navigate(-1); + }; + + return ( + window.location.replace(upgradeCdnlUrl)} + > + {t('cdn_shared_change_offer_modal_info')} + + ); +} diff --git a/packages/manager/apps/web-hosting/src/pages/dashboard/multisite/cdn/CdnCacheRule.modal.spec.tsx b/packages/manager/apps/web-hosting/src/pages/dashboard/multisite/cdn/CdnCacheRule.modal.spec.tsx new file mode 100644 index 000000000000..2a12c1d6e394 --- /dev/null +++ b/packages/manager/apps/web-hosting/src/pages/dashboard/multisite/cdn/CdnCacheRule.modal.spec.tsx @@ -0,0 +1,37 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { navigate } from '@/utils/test.setup'; + +import CdnCacheRuleModal from './CdnCacheRule.modal'; + +const queryClient = new QueryClient(); + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ t: (k: string) => k }), + Trans: ({ i18nKey }: { i18nKey: string }) => i18nKey, +})); + +describe('CdnCacheRuleModal', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + // @TODO: this test can fail randomly for no apparent reason, I think there's + // an issue in ODS that cause `has-error` to be empty randomly so let's + // unskip this test when it is fixed + it.skip('should close modal on cancel', () => { + render( + + + , + ); + + const cancelBtn = screen.getByTestId('secondary-button'); + expect(cancelBtn).not.toBeNull(); + fireEvent.click(cancelBtn); + + expect(navigate).toHaveBeenCalled(); + }); +}); diff --git a/packages/manager/apps/web-hosting/src/pages/dashboard/multisite/cdn/CdnCacheRule.modal.tsx b/packages/manager/apps/web-hosting/src/pages/dashboard/multisite/cdn/CdnCacheRule.modal.tsx new file mode 100644 index 000000000000..71d783af4852 --- /dev/null +++ b/packages/manager/apps/web-hosting/src/pages/dashboard/multisite/cdn/CdnCacheRule.modal.tsx @@ -0,0 +1,309 @@ +import { useLocation, useNavigate, useParams } from 'react-router-dom'; + +import { zodResolver } from '@hookform/resolvers/zod'; +import { Controller, useForm } from 'react-hook-form'; +import { useTranslation } from 'react-i18next'; +import { z } from 'zod'; + +import { ODS_TEXT_PRESET } from '@ovhcloud/ods-components'; +import { + OdsInput, + OdsQuantity, + OdsRadio, + OdsSelect, + OdsText, +} from '@ovhcloud/ods-components/react'; + +import { NAMESPACES } from '@ovh-ux/manager-common-translations'; +import { Modal } from '@ovh-ux/manager-react-components'; + +import { CDN_ADVANCED } from '@/constants'; +import { + useCreateCdnOption, + useGetCdnOption, + useGetServiceNameCdn, + useUpdateCdnOptions, +} from '@/data/hooks/cdn/useCdn'; +import { CdnOption, CdnOptionType, PatternType } from '@/data/types/product/cdn'; +import { convertToTtl, convertToUnitTime } from '@/utils/cdn'; + +const formSchema = z.object({ + ruleName: z.string(), + patternType: z.string(), + pattern: z.string(), + ttl: z.number(), + ttlUnit: z.string(), + priority: z.number(), +}); +type FormData = z.infer; + +export default function CdnCacheRuleModal() { + const { serviceName, domain } = useParams(); + const navigate = useNavigate(); + const location = useLocation(); + const modifiedOption = location.state as CdnOption; + const { t } = useTranslation(['dashboard', NAMESPACES.ACTIONS]); + const serviceNameCdn = useGetServiceNameCdn(serviceName); + const optionsData = useGetCdnOption(serviceName, domain); + const { createCdnOption } = useCreateCdnOption(serviceName, domain); + const { updateCdnOptions } = useUpdateCdnOptions(serviceName, domain); + const enableOnlyExtension = serviceNameCdn?.data?.type !== CDN_ADVANCED; + const unitTime = convertToUnitTime(modifiedOption?.config?.ttl, t); + const { + control, + handleSubmit, + watch, + formState: { isDirty }, + } = useForm({ + defaultValues: { + ruleName: modifiedOption?.name || '', + patternType: modifiedOption?.config?.patternType || 'extension', + pattern: modifiedOption?.pattern || '', + ttl: unitTime?.timeValue || null, + ttlUnit: unitTime?.timeUnit || null, + priority: modifiedOption?.config?.priority || null, + }, + resolver: zodResolver(formSchema), + }); + const ruleValues = watch(); + const canValidate = modifiedOption + ? isDirty + : Object.values(ruleValues).every((value) => Boolean(value)); + + const onClose = () => { + navigate(-1); + }; + + const onSubmit = (data: FormData) => { + const cdnOption = { + type: CdnOptionType.CACHE_RULE, + name: data?.ruleName, + pattern: data?.pattern, + enabled: true, + config: { + ttl: convertToTtl(data?.ttl, data?.ttlUnit, t), + priority: data?.priority, + patternType: data?.patternType as PatternType, + }, + }; + const updatePriority = optionsData?.data?.find( + (item) => item?.config?.priority === data?.priority, + ); + const optionsToUpdate = updatePriority + ? optionsData?.data + ?.filter( + (option) => + option?.type === CdnOptionType.CACHE_RULE && + option?.config?.priority >= data?.priority, + ) + .map((option) => ({ + ...option, + config: { + ...option?.config, + priority: option?.config?.priority + 1, + }, + })) + : []; + const cdnOptions = modifiedOption ? [cdnOption, ...optionsToUpdate] : optionsToUpdate; + + if (!modifiedOption) createCdnOption({ cdnOption }); + if (cdnOptions.length) updateCdnOptions({ cdnOptions }); + onClose(); + }; + + return ( +
+ void handleSubmit(onSubmit)()} + isPrimaryButtonDisabled={!canValidate} + > +
+ {t('cdn_shared_modal_add_rule_info')} + + {t('cdn_shared_option_cache_rule_table_header_rule_name')} + + ( + field.onChange(e.target.value)} + /> + )} + /> + + {t('cdn_shared_modal_add_rule_field_resource_type')} + + + {t(`cdn_shared_modal_add_rule_${enableOnlyExtension ? 'resource_info' : 'info'}`)} + + ( + <> +
+ field.onChange('extension')} + /> + +
+
+ field.onChange('folder')} + /> + +
+
+ field.onChange('regex')} + /> + +
+
+ field.onChange('uri')} + /> + +
+ + )} + /> + + {t('cdn_shared_modal_add_rule_field_resource')} + + {t('cdn_shared_modal_add_rule_field_resource_extension_info')} + ( + field.onChange(e.target.value)} + /> + )} + /> + + {t('cdn_shared_modal_add_rule_field_time_to_live')} + + {t('cdn_shared_modal_add_rule_field_time_to_live_info')} +
+ ( + field.onChange(e.target.value)} + /> + )} + /> + ( + field.onChange(e.target.value)} + > + + + + + )} + /> +
+ + {t('cdn_shared_modal_add_rule_field_order_by')} + + {t('cdn_shared_modal_add_rule_field_order_by_info')} + ( + field.onChange(e.detail.value)} + /> + )} + /> +
+
+
+ ); +} diff --git a/packages/manager/apps/web-hosting/src/pages/dashboard/multisite/cdn/CdnConfirmation.modal.spec.tsx b/packages/manager/apps/web-hosting/src/pages/dashboard/multisite/cdn/CdnConfirmation.modal.spec.tsx new file mode 100644 index 000000000000..de8d6896751e --- /dev/null +++ b/packages/manager/apps/web-hosting/src/pages/dashboard/multisite/cdn/CdnConfirmation.modal.spec.tsx @@ -0,0 +1,34 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { navigate } from '@/utils/test.setup'; + +import CdnConfirmationModal from './CdnConfirmation.modal'; + +const queryClient = new QueryClient(); + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ t: (k: string) => k }), + Trans: ({ i18nKey }: { i18nKey: string }) => i18nKey, +})); + +describe('CdnConfirmationModal', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should close modal on cancel', () => { + render( + + + , + ); + + const cancelBtn = screen.getByTestId('secondary-button'); + expect(cancelBtn).not.toBeNull(); + fireEvent.click(cancelBtn); + + expect(navigate).toHaveBeenCalled(); + }); +}); diff --git a/packages/manager/apps/web-hosting/src/pages/dashboard/multisite/cdn/CdnConfirmation.modal.tsx b/packages/manager/apps/web-hosting/src/pages/dashboard/multisite/cdn/CdnConfirmation.modal.tsx new file mode 100644 index 000000000000..1f4e96ab35d2 --- /dev/null +++ b/packages/manager/apps/web-hosting/src/pages/dashboard/multisite/cdn/CdnConfirmation.modal.tsx @@ -0,0 +1,234 @@ +import { useLocation, useNavigate, useParams } from 'react-router-dom'; + +import { useTranslation } from 'react-i18next'; + +import { ODS_TEXT_PRESET } from '@ovhcloud/ods-components'; +import { OdsText } from '@ovhcloud/ods-components/react'; + +import { NAMESPACES } from '@ovh-ux/manager-common-translations'; +import { Modal } from '@ovh-ux/manager-react-components'; + +import { SHARED_CDN_OPTIONS } from '@/constants'; +import { useUpdateCdnOptions } from '@/data/hooks/cdn/useCdn'; +import { CdnFormValues, CdnOption, CdnOptionType } from '@/data/types/product/cdn'; +import { subRoutes, urls } from '@/routes/routes.constants'; + +type LocationState = { + formData?: CdnFormValues; + optionsData?: CdnOption[]; +}; + +export default function CdnConfirmationModal() { + const { serviceName, domain } = useParams(); + const location = useLocation(); + const state = location.state as LocationState | undefined; + const optionsData = state?.optionsData; + const modifiedOption = state?.formData; + const { updateCdnOptions } = useUpdateCdnOptions(serviceName, domain); + + const { t } = useTranslation(['dashboard', NAMESPACES.ACTIONS, NAMESPACES.SERVICE]); + const navigate = useNavigate(); + const onClose = () => { + navigate(-1); + }; + + const updateOption = (option: CdnOption): CdnOption => { + switch (option?.type) { + case CdnOptionType.CORS: + return { + config: { + resources: modifiedOption?.corsResources, + ...option?.config, + }, + ...option, + }; + case CdnOptionType.PREWARM: + return { + config: { + resources: modifiedOption?.corsResources, + ...option?.config, + }, + ...option, + }; + case CdnOptionType.MOBILE_REDIRECT: { + const followUri = + modifiedOption?.mobileRedirectType === SHARED_CDN_OPTIONS.MOBILE_REDIRECT.STILL_URL; + return { + config: { + followUri, + destination: followUri + ? option?.config?.destination + : modifiedOption?.mobileRedirectUrl, + }, + ...option, + }; + } + case CdnOptionType.QUERYSTRING: + return { + config: { + queryParameters: modifiedOption?.querytringParam, + ...option?.config, + }, + ...option, + }; + case CdnOptionType.HSTS: + return { + config: { + ttl: modifiedOption?.hstsAge * modifiedOption?.hstUnit, + ...option?.config, + }, + ...option, + }; + default: + return option; + } + }; + + const onConfirm = () => { + const cdnOptions: CdnOption[] = []; + Object.entries(modifiedOption).forEach(([key, value]) => { + const snakeKey = key.replace(/[A-Z]/g, (l) => `_${l.toLowerCase()}`); + if (typeof value === 'boolean') { + const current = optionsData?.find((item) => item?.name === snakeKey); + if (current && current?.enabled !== value) { + const updatedOption = updateOption({ ...current, enabled: value }); + cdnOptions.push(updatedOption); + } + } + }); + updateCdnOptions({ cdnOptions }); + navigate(urls.multisite.replace(subRoutes.serviceName, serviceName)); + }; + + return ( + +
+ {t('cdn_shared_modal_confirm_info')} + + {t('cdn_shared_modal_confirm_perf')} +
+ + {t('cdn_shared_option_always_online_title')} + + + {t(`${NAMESPACES.SERVICE}:service_state_enabled`)} + +
+
+ + {t('cdn_shared_option_http_https_title')} + + + {t(`${NAMESPACES.SERVICE}:service_state_enabled`)} + +
+
+ + {t('cdn_shared_option_brotli_title')} + + + {modifiedOption?.brotli && t(`${NAMESPACES.SERVICE}:service_state_enabled`)} + +
+
+ + {t('cdn_shared_option_http_geolocation_title')} + + + {modifiedOption?.geoHeaders && t(`${NAMESPACES.SERVICE}:service_state_enabled`)} + +
+
+ + {t('cdn_shared_option_prefetch_title')} + + + {modifiedOption?.prefetch && t(`${NAMESPACES.SERVICE}:service_state_enabled`)} + +
+
+ + {t('cdn_shared_option_mobile_redirect_title')} + + + {modifiedOption?.mobileRedirect && t(`${NAMESPACES.SERVICE}:service_state_enabled`)} + +
+ + {t('cdn_shared_modal_confirm_cache')} +
+ + {t('cdn_shared_option_dev_mode_title')} + + + {modifiedOption?.devmode && t(`${NAMESPACES.SERVICE}:service_state_enabled`)} + +
+
+ + {t('cdn_shared_option_query_string_title')} + + + {modifiedOption?.querystring && t(`${NAMESPACES.SERVICE}:service_state_enabled`)} + +
+
+ + {t('cdn_shared_option_prewarm_title')} + + + {modifiedOption?.prewarm && t(`${NAMESPACES.SERVICE}:service_state_enabled`)} + +
+ + {t('cdn_shared_modal_confirm_security')} +
+ + Cross-Origin Resource Sharing (CORS) + + {modifiedOption?.cors} +
+
+ + HTTPS-Redirect + + + {modifiedOption?.httpsRedirect && t(`${NAMESPACES.SERVICE}:service_state_enabled`)} + +
+
+ + HTTP Strict Transport Security (HSTS) + + + {modifiedOption?.hsts && t(`${NAMESPACES.SERVICE}:service_state_enabled`)} + +
+
+ + Mixed-Content + + + {modifiedOption?.mixedContent && t(`${NAMESPACES.SERVICE}:service_state_enabled`)} + +
+
+ + Web Application Firewall (WAF) + + + {modifiedOption?.waf && t(`${NAMESPACES.SERVICE}:service_state_enabled`)} + +
+
+
+ ); +} diff --git a/packages/manager/apps/web-hosting/src/pages/dashboard/multisite/cdn/CdnCorsResourceSharing.modal.spec.tsx b/packages/manager/apps/web-hosting/src/pages/dashboard/multisite/cdn/CdnCorsResourceSharing.modal.spec.tsx new file mode 100644 index 000000000000..111067a8025e --- /dev/null +++ b/packages/manager/apps/web-hosting/src/pages/dashboard/multisite/cdn/CdnCorsResourceSharing.modal.spec.tsx @@ -0,0 +1,34 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { navigate } from '@/utils/test.setup'; + +import CdnCorsResourceSharingModal from './CdnCorsResourceSharing.modal'; + +const queryClient = new QueryClient(); + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ t: (k: string) => k }), + Trans: ({ i18nKey }: { i18nKey: string }) => i18nKey, +})); + +describe('CdnCorsResourceSharingModal', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should close modal on cancel', () => { + render( + + + , + ); + + const cancelBtn = screen.getByTestId('secondary-button'); + expect(cancelBtn).not.toBeNull(); + fireEvent.click(cancelBtn); + + expect(navigate).toHaveBeenCalled(); + }); +}); diff --git a/packages/manager/apps/web-hosting/src/pages/dashboard/multisite/cdn/CdnCorsResourceSharing.modal.tsx b/packages/manager/apps/web-hosting/src/pages/dashboard/multisite/cdn/CdnCorsResourceSharing.modal.tsx new file mode 100644 index 000000000000..9acb7d24b029 --- /dev/null +++ b/packages/manager/apps/web-hosting/src/pages/dashboard/multisite/cdn/CdnCorsResourceSharing.modal.tsx @@ -0,0 +1,84 @@ +import { useState } from 'react'; + +import { useNavigate, useParams } from 'react-router-dom'; + +import { useTranslation } from 'react-i18next'; + +import { ODS_BUTTON_COLOR, ODS_BUTTON_VARIANT, ODS_TEXT_PRESET } from '@ovhcloud/ods-components'; +import { OdsButton, OdsInput, OdsText } from '@ovhcloud/ods-components/react'; + +import { NAMESPACES } from '@ovh-ux/manager-common-translations'; +import { Modal } from '@ovh-ux/manager-react-components'; + +import { useGetCdnOption } from '@/data/hooks/cdn/useCdn'; +import { CdnOptionType } from '@/data/types/product/cdn'; +import { findOption } from '@/utils/cdn'; + +export default function CdnCorsResourceSharingModal() { + const { serviceName, domain } = useParams(); + const { t } = useTranslation(['dashboard', NAMESPACES.ACTIONS]); + const { data } = useGetCdnOption(serviceName, domain); + const corsData = findOption(data, CdnOptionType.CORS); + const [addedDomain, setAddedDomain] = useState(''); + const [domainLists, setDomainLists] = useState(corsData?.config?.resources); + const [selectedDomain, setSelectedDomain] = useState(''); + const navigate = useNavigate(); + + const onClose = () => { + navigate(-1); + }; + + return ( + {}} + > +
+ {t('cdn_shared_cors_description')} + {t('cdn_shared_cors_add_domain')} + setAddedDomain(e.target.value as string)} + /> + { + setDomainLists([...domainLists, addedDomain]); + }} + /> + {t('cdn_shared_cors_domain_list')} + + { + setDomainLists(domainLists?.filter((item) => item !== selectedDomain)); + }} + /> +
+
+ ); +} diff --git a/packages/manager/apps/web-hosting/src/pages/dashboard/multisite/cdn/CdnEditUrls.modal.spec.tsx b/packages/manager/apps/web-hosting/src/pages/dashboard/multisite/cdn/CdnEditUrls.modal.spec.tsx new file mode 100644 index 000000000000..746fa602b661 --- /dev/null +++ b/packages/manager/apps/web-hosting/src/pages/dashboard/multisite/cdn/CdnEditUrls.modal.spec.tsx @@ -0,0 +1,37 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { navigate } from '@/utils/test.setup'; + +import CdnEditUrlsModal from './CdnEditUrls.modal'; + +const queryClient = new QueryClient(); + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ t: (k: string) => k }), + Trans: ({ i18nKey }: { i18nKey: string }) => i18nKey, +})); + +describe('CdnEditUrlsModal', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + // @TODO: this test can fail randomly for no apparent reason, I think there's + // an issue in ODS that cause `has-error` to be empty randomly so let's + // unskip this test when it is fixed + it.skip('should close modal on cancel', () => { + render( + + + , + ); + + const cancelBtn = screen.getByTestId('secondary-button'); + expect(cancelBtn).not.toBeNull(); + fireEvent.click(cancelBtn); + + expect(navigate).toHaveBeenCalled(); + }); +}); diff --git a/packages/manager/apps/web-hosting/src/pages/dashboard/multisite/cdn/CdnEditUrls.modal.tsx b/packages/manager/apps/web-hosting/src/pages/dashboard/multisite/cdn/CdnEditUrls.modal.tsx new file mode 100644 index 000000000000..63333a3c5efb --- /dev/null +++ b/packages/manager/apps/web-hosting/src/pages/dashboard/multisite/cdn/CdnEditUrls.modal.tsx @@ -0,0 +1,137 @@ +import { useState } from 'react'; + +import { useNavigate, useParams } from 'react-router-dom'; + +import { useTranslation } from 'react-i18next'; + +import { ODS_BUTTON_COLOR, ODS_BUTTON_VARIANT, ODS_TEXT_PRESET } from '@ovhcloud/ods-components'; +import { OdsButton, OdsInput, OdsSelect, OdsText } from '@ovhcloud/ods-components/react'; + +import { NAMESPACES } from '@ovh-ux/manager-common-translations'; +import { Modal } from '@ovh-ux/manager-react-components'; + +import { MAX_URL_ENTRIES } from '@/constants'; +import { useGetCdnOption } from '@/data/hooks/cdn/useCdn'; +import { CdnOptionType } from '@/data/types/product/cdn'; +import { findOption } from '@/utils/cdn'; + +export default function CdnEditUrlsModal() { + const { serviceName, domain } = useParams(); + const { t } = useTranslation(['dashboard', NAMESPACES.ACTIONS]); + const { data } = useGetCdnOption(serviceName, domain); + const prewarmData = findOption(data, CdnOptionType.PREWARM); + const [urlLists, setUrlLists] = useState(prewarmData?.config?.resources); + const [selectedUrl, setSelectedUrl] = useState(''); + const [protocole, setProtocole] = useState( + t('cdn_shared_change_edit_urls_modal_propdown_protocol_HTTP'), + ); + const [ressourcePath, setRessourcePath] = useState(''); + const newUrl = `${protocole.toLowerCase()}://${domain}/${ressourcePath}`; + const navigate = useNavigate(); + + const onClose = () => { + navigate(-1); + }; + + return ( + {}} + > +
+ {t('cdn_shared_change_edit_urls_modal_info')} +
+ + {t('cdn_shared_change_edit_urls_modal_protocol_label')} + + + {t('cdn_shared_change_edit_urls_modal_domain_label')} + + + {t('cdn_shared_change_edit_urls_modal_resource_label')} + +
+
+ setProtocole(event.detail.value)} + > + + + + + setRessourcePath(e.target.value as string)} + /> +
+ {newUrl} + { + setUrlLists([...urlLists, newUrl]); + }} + /> + + {t('cdn_shared_change_edit_urls_modal_url_to_preload_label')} + + + {t('cdn_shared_change_edit_urls_modal_url_to_preload_info', { + numberOfUrls: urlLists?.length, + maxUrls: MAX_URL_ENTRIES, + })} + + + = MAX_URL_ENTRIES} + onClick={() => { + setUrlLists(urlLists?.filter((item) => item !== selectedUrl)); + }} + /> +
+
+ ); +} diff --git a/packages/manager/apps/web-hosting/src/pages/dashboard/multisite/cdn/ModifyCdn.page.tsx b/packages/manager/apps/web-hosting/src/pages/dashboard/multisite/cdn/ModifyCdn.page.tsx index d9783d638988..83705fe2009e 100644 --- a/packages/manager/apps/web-hosting/src/pages/dashboard/multisite/cdn/ModifyCdn.page.tsx +++ b/packages/manager/apps/web-hosting/src/pages/dashboard/multisite/cdn/ModifyCdn.page.tsx @@ -1,3 +1,118 @@ +import { useEffect } from 'react'; + +import { useNavigate, useParams } from 'react-router-dom'; + +import { useForm } from 'react-hook-form'; +import { useTranslation } from 'react-i18next'; + +import { + ODS_BUTTON_COLOR, + ODS_BUTTON_VARIANT, + ODS_MESSAGE_COLOR, + ODS_TEXT_PRESET, +} from '@ovhcloud/ods-components'; +import { OdsButton, OdsMessage, OdsText } from '@ovhcloud/ods-components/react'; + +import { NAMESPACES } from '@ovh-ux/manager-common-translations'; +import { Links } from '@ovh-ux/manager-react-components'; + +import { CDN_ADVANCED } from '@/constants'; +import { useGetCdnOption, useGetServiceNameCdn } from '@/data/hooks/cdn/useCdn'; +import { CdnFormValues } from '@/data/types/product/cdn'; +import { subRoutes, urls } from '@/routes/routes.constants'; +import { cdnFormDefaultValues } from '@/utils/cdn'; + +import CdnRuleDatagrid from './component/CdnRuleDatagrid'; +import { OptionCache } from './component/OptionCache'; +import { OptionPerformance } from './component/OptionPerformance'; +import { OptionSecurity } from './component/OptionSecurity'; + export default function ModifyCdnPage() { - return <>ModifyCdnPage; + const { serviceName, domain } = useParams(); + const navigate = useNavigate(); + + const cdn = useGetServiceNameCdn(serviceName); + const { data } = useGetCdnOption(serviceName, domain); + const { t } = useTranslation(['dashboard', NAMESPACES.ACTIONS]); + const range = cdn?.data?.type?.replace(/[-]+/g, ' ').toUpperCase(); + + const defaultValues: CdnFormValues = cdnFormDefaultValues(data); + + const { + control, + handleSubmit, + watch, + reset, + formState: { isDirty }, + } = useForm({ + defaultValues, + }); + + useEffect(() => { + if (!data) return; + const next = cdnFormDefaultValues(data); + + reset(next); + }, [data, reset]); + + const onSubmit = (formData: CdnFormValues) => { + navigate( + urls.cdnConfirmation + .replace(subRoutes.serviceName, serviceName) + .replace(subRoutes.domain, domain), + { + state: { + formData, + optionsData: data, + }, + }, + ); + }; + + const controlValues = watch(); + + return ( +
+ + {t('cdn_shared_title', { + domain, + range, + })} + + {t('cdn_shared_info')} + + {t('cdn_shared_help')} + + + + + + + + + + + +
+ navigate(-1)} + /> + void handleSubmit(onSubmit)()} + /> +
+ + ); } diff --git a/packages/manager/apps/web-hosting/src/pages/dashboard/multisite/cdn/component/CdnRuleDatagrid.spec.tsx b/packages/manager/apps/web-hosting/src/pages/dashboard/multisite/cdn/component/CdnRuleDatagrid.spec.tsx new file mode 100644 index 000000000000..4b85a25b7392 --- /dev/null +++ b/packages/manager/apps/web-hosting/src/pages/dashboard/multisite/cdn/component/CdnRuleDatagrid.spec.tsx @@ -0,0 +1,25 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { render, screen } from '@testing-library/react'; +import { describe, it } from 'vitest'; + +import CdnRuleDatagrid from './CdnRuleDatagrid'; + +const queryClient = new QueryClient(); + +describe('useDatagridColumn', () => { + it.skip('should return correct columns', () => { + render( + + + , + ); + + expect(screen.getByTestId('header-priority')).not.toBeNull(); + expect(screen.getByTestId('header-rule')).not.toBeNull(); + expect(screen.getByTestId('header-type')).not.toBeNull(); + expect(screen.getByTestId('header-resource')).not.toBeNull(); + expect(screen.getByTestId('header-time')).not.toBeNull(); + expect(screen.getByTestId('header-status')).not.toBeNull(); + expect(screen.getByTestId('header-action')).not.toBeNull(); + }); +}); diff --git a/packages/manager/apps/web-hosting/src/pages/dashboard/multisite/cdn/component/CdnRuleDatagrid.tsx b/packages/manager/apps/web-hosting/src/pages/dashboard/multisite/cdn/component/CdnRuleDatagrid.tsx new file mode 100644 index 000000000000..92029419b2b2 --- /dev/null +++ b/packages/manager/apps/web-hosting/src/pages/dashboard/multisite/cdn/component/CdnRuleDatagrid.tsx @@ -0,0 +1,230 @@ +import React from 'react'; + +import { useNavigate, useParams } from 'react-router-dom'; + +import punycode from 'punycode/punycode'; +import { useTranslation } from 'react-i18next'; + +import { + ODS_BUTTON_SIZE, + ODS_BUTTON_VARIANT, + ODS_ICON_NAME, + ODS_TEXT_PRESET, +} from '@ovhcloud/ods-components'; +import { OdsButton, OdsText, OdsToggle } from '@ovhcloud/ods-components/react'; + +import { NAMESPACES } from '@ovh-ux/manager-common-translations'; +import { FilterTypeCategories } from '@ovh-ux/manager-core-api'; +import { + ActionMenu, + ActionMenuItem, + DataGridTextCell, + Datagrid, + DatagridColumn, + useResourcesIcebergV6, +} from '@ovh-ux/manager-react-components'; + +import Loading from '@/components/loading/Loading.component'; +import { useDeleteCdnOption, useUpdateCdnOption } from '@/data/hooks/cdn/useCdn'; +import { CdnOption, CdnOptionType } from '@/data/types/product/cdn'; +import { useDebouncedValue } from '@/hooks/debouncedValue/useDebouncedValue'; +import { subRoutes, urls } from '@/routes/routes.constants'; +import { buildURLSearchParams } from '@/utils'; +import { convertToUnitTime } from '@/utils/cdn'; + +export default function CdnRuleDatagrid({ range }: { range: string }) { + const { serviceName, domain } = useParams(); + const navigate = useNavigate(); + const [searchInput, setSearchInput, debouncedSearchInput, setDebouncedSearchInput] = + useDebouncedValue(''); + const { t } = useTranslation(['dashboard', NAMESPACES.DASHBOARD, NAMESPACES.STATUS]); + const searchParams = buildURLSearchParams({ + name: punycode.toASCII(debouncedSearchInput), + }); + const { flattenData, hasNextPage, fetchNextPage, isLoading, filters } = + useResourcesIcebergV6({ + route: `/hosting/web/${serviceName}/cdn/domain/${domain}/option${searchParams}`, + queryKey: ['hosting', 'web', serviceName, 'cdn', 'domain', domain, 'option'], + }); + + const { updateCdnOption } = useUpdateCdnOption(serviceName, domain); + const { deleteCdnOption } = useDeleteCdnOption(serviceName, domain); + + const rulesData = flattenData + ?.filter((item) => item?.type === CdnOptionType.CACHE_RULE) + .sort((a, b) => a.config.priority - b.config.priority); + + const TopbarCTA = () => ( + + navigate( + urls.cdnCacheRule + .replace(subRoutes.serviceName, serviceName) + .replace(subRoutes.domain, domain), + ) + } + /> + ); + + const DatagridActionCell = (props: CdnOption) => { + const items: ActionMenuItem[] = [ + { + id: 1, + label: t('cdn_shared_option_cache_rule_table_items_option_set_rule'), + onClick: () => { + navigate( + urls.cdnCacheRule + .replace(subRoutes.serviceName, serviceName) + .replace(subRoutes.domain, domain), + { state: props }, + ); + }, + }, + { + id: 2, + label: t('cdn_shared_option_cache_rule_table_items_option_delete_rule'), + onClick: () => deleteCdnOption(props?.name), + }, + ]; + + return ( + + + + ); + }; + + const columns: DatagridColumn[] = [ + { + id: 'priority', + label: t('cdn_shared_option_cache_rule_table_header_order_by'), + isSortable: true, + isFilterable: true, + type: FilterTypeCategories.Numeric, + cell: (row) => {row?.config?.priority}, + }, + { + id: 'rule', + label: t('cdn_shared_option_cache_rule_table_header_rule_name'), + isSortable: true, + isSearchable: true, + isFilterable: true, + type: FilterTypeCategories.String, + cell: (row) => {row.name}, + }, + { + id: 'type', + label: t(`${NAMESPACES.DASHBOARD}:type`), + isSortable: true, + isFilterable: true, + type: FilterTypeCategories.String, + cell: (row) => ( + + {t(`cdn_shared_option_cache_rule_table_items_type_${row.config?.patternType}`)} + + ), + }, + { + id: 'resource', + label: t('cdn_shared_option_cache_rule_table_header_resource'), + isSortable: true, + isFilterable: true, + type: FilterTypeCategories.String, + cell: (row) => {row.pattern}, + }, + { + id: 'time', + label: t('cdn_shared_option_cache_rule_table_time_to_live'), + isSortable: true, + isFilterable: true, + type: FilterTypeCategories.Numeric, + cell: (row) => { + const unitTime = convertToUnitTime(row?.config?.ttl, t); + return ( + {`${unitTime?.timeValue} ${unitTime?.timeUnit}`} + ); + }, + }, + { + id: 'status', + label: t(`${NAMESPACES.STATUS}:status`), + isSortable: true, + isFilterable: true, + type: FilterTypeCategories.Options, + cell: (row) => { + const { name, enabled, ...payload } = row; + return ( + +
+ { + updateCdnOption({ + option: name, + cdnOption: { + enabled: !enabled, + ...payload, + }, + }); + }} + /> + + {t( + row.enabled + ? t(`${NAMESPACES.SERVICE}:service_state_enabled`) + : t(`${NAMESPACES.SERVICE}:service_state_disabled`), + )} + +
+
+ ); + }, + }, + { + id: 'action', + label: '', + isSortable: true, + cell: DatagridActionCell, + }, + ]; + + return ( + }> + {t('cdn_shared_option_cache_rule_title')} + + {t('cdn_shared_option_cache_rule_add_rule_max_rules', { + range, + maxItems: 5, + })} + + {columns && ( + setDebouncedSearchInput(search), + }} + topbar={} + /> + )} + + ); +} diff --git a/packages/manager/apps/web-hosting/src/pages/dashboard/multisite/cdn/component/CdnToogleCard.tsx b/packages/manager/apps/web-hosting/src/pages/dashboard/multisite/cdn/component/CdnToogleCard.tsx new file mode 100644 index 000000000000..7f4987565e99 --- /dev/null +++ b/packages/manager/apps/web-hosting/src/pages/dashboard/multisite/cdn/component/CdnToogleCard.tsx @@ -0,0 +1,72 @@ +import React, { useEffect, useState } from 'react'; + +import { useTranslation } from 'react-i18next'; + +import { + ODS_TEXT_PRESET, + OdsToggleChangeEventDetail, + OdsToggleCustomEvent, +} from '@ovhcloud/ods-components'; +import { OdsText, OdsToggle } from '@ovhcloud/ods-components/react'; + +import { NAMESPACES } from '@ovh-ux/manager-common-translations'; + +interface ToggleCardProps { + title: string; + info: string; + name?: string; + toggleValue?: boolean; + isDisabled?: boolean; + onToggle?: (event: unknown) => void; + children?: React.ReactNode; +} + +// OdsToggle web component seems to have an issue where the new value is not always detected +// As a temporary solution, the value is kept in sync with an internal state, like in sap-features-hub. +// TODO: ODS migration +export const ToggleCard: React.FC = ({ + name, + title, + info, + toggleValue, + isDisabled = false, + onToggle, + children, +}) => { + const { t } = useTranslation([NAMESPACES.SERVICE]); + const [internalChecked, setInternalChecked] = useState(!!toggleValue); + + const toggleId = `${name}-toggle`; + + useEffect(() => { + setInternalChecked(!!toggleValue); + }, [toggleValue]); + + return ( +
+ {title} + {info} + {children &&
{children}
} +
+ ) => { + const newValue = !!e.detail.value; + setInternalChecked(newValue); + onToggle(newValue); + }} + /> + + {t( + toggleValue + ? t(`${NAMESPACES.SERVICE}:service_state_enabled`) + : t(`${NAMESPACES.SERVICE}:service_state_disabled`), + )} + +
+
+ ); +}; diff --git a/packages/manager/apps/web-hosting/src/pages/dashboard/multisite/cdn/component/OptionCache.tsx b/packages/manager/apps/web-hosting/src/pages/dashboard/multisite/cdn/component/OptionCache.tsx new file mode 100644 index 000000000000..8a8574ef1efb --- /dev/null +++ b/packages/manager/apps/web-hosting/src/pages/dashboard/multisite/cdn/component/OptionCache.tsx @@ -0,0 +1,170 @@ +import React from 'react'; + +import { useNavigate, useParams } from 'react-router-dom'; + +import { Control, Controller } from 'react-hook-form'; +import { useTranslation } from 'react-i18next'; + +import { ODS_BUTTON_COLOR, ODS_BUTTON_VARIANT, ODS_TEXT_PRESET } from '@ovhcloud/ods-components'; +import { + OdsButton, + OdsFormField, + OdsProgressBar, + OdsSelect, + OdsText, + OdsToggle, +} from '@ovhcloud/ods-components/react'; + +import { NAMESPACES } from '@ovh-ux/manager-common-translations'; + +import { + CdnFormValues, + CdnOption, + CdnOptionType, + CdnQueryParameters, +} from '@/data/types/product/cdn'; +import { subRoutes, urls } from '@/routes/routes.constants'; +import { getPrewarmQuotaPercentage, getQuotaUsage, hasOption } from '@/utils/cdn'; + +import { ToggleCard } from './CdnToogleCard'; + +interface OptionCacheProps { + controlValues: CdnFormValues; + control: Control; + optionsData: CdnOption[]; + advancedPurge: boolean; +} + +export const OptionCache: React.FC = ({ + controlValues, + control, + optionsData, + advancedPurge, +}) => { + const { t } = useTranslation(['dashboard']); + const { serviceName, domain } = useParams(); + const navigate = useNavigate(); + + return ( + <> + {t('cdn_shared_option_category_cache')} + {hasOption(optionsData, CdnOptionType.DEVMODE) && ( + ( + + )} + /> + )} +
+ + {t('cdn_shared_option_advanced_flush_title')} + + + {t( + `cdn_shared_option_advanced_flush_info${ + advancedPurge ? '' : '_for_basic_and_security' + }`, + )} + + + { + navigate( + urls.advancedFlushCdn + .replace(subRoutes.serviceName, serviceName) + .replace(subRoutes.domain, domain), + ); + }} + /> + {t(`${NAMESPACES.SERVICE}:service_state_disabled`)} + +
+ {hasOption(optionsData, CdnOptionType.QUERYSTRING) && ( + ( + +
+ {t('cdn_shared_option_query_string_sort_ignored')} + {t('cdn_shared_option_query_string_sort_true')} + {t('cdn_shared_option_query_string_sort_false')} +
+
+ )} + /> + )} + {controlValues?.querystring && ( + ( + field.onChange(e.target.value)} + > + + + + )} + /> + )} + {hasOption(optionsData, CdnOptionType.PREWARM) && ( + ( + + )} + /> + )} + {controlValues?.prewarm && ( + <> + + {t('cdn_shared_option_prewarm_quota')} + + {getQuotaUsage(optionsData)} + + + navigate( + urls.cdnEditUrls + .replace(subRoutes.serviceName, serviceName) + .replace(subRoutes.domain, domain), + ) + } + /> + + )} + + ); +}; diff --git a/packages/manager/apps/web-hosting/src/pages/dashboard/multisite/cdn/component/OptionPerformance.tsx b/packages/manager/apps/web-hosting/src/pages/dashboard/multisite/cdn/component/OptionPerformance.tsx new file mode 100644 index 000000000000..3fe29e399167 --- /dev/null +++ b/packages/manager/apps/web-hosting/src/pages/dashboard/multisite/cdn/component/OptionPerformance.tsx @@ -0,0 +1,157 @@ +import React, { useContext } from 'react'; + +import { Control, Controller } from 'react-hook-form'; +import { useTranslation } from 'react-i18next'; + +import { ODS_TEXT_PRESET } from '@ovhcloud/ods-components'; +import { OdsInput, OdsSelect, OdsText } from '@ovhcloud/ods-components/react'; + +import { IconLinkAlignmentType, Links } from '@ovh-ux/manager-react-components'; +import { ShellContext } from '@ovh-ux/manager-react-shell-client'; + +import { SHARED_CDN_OPTIONS } from '@/constants'; +import { CdnFormValues, CdnOption, CdnOptionType } from '@/data/types/product/cdn'; +import { hasOption } from '@/utils/cdn'; + +import { ToggleCard } from './CdnToogleCard'; + +interface OptionPerformanceProps { + controlValues: CdnFormValues; + control: Control; + optionsData: CdnOption[]; +} + +export const OptionPerformance: React.FC = ({ + controlValues, + control, + optionsData, +}) => { + const { t } = useTranslation(['dashboard']); + const context = useContext(ShellContext); + + const { ovhSubsidiary } = context.environment.getUser(); + + const sharedOptionUrl = + SHARED_CDN_OPTIONS.PREFETCH.LINKS[ + ovhSubsidiary as keyof typeof SHARED_CDN_OPTIONS.PREFETCH.LINKS + ] ?? SHARED_CDN_OPTIONS.PREFETCH.LINKS.DEFAULT; + + return ( + <> + + {t('cdn_shared_option_category_performance')} + + + + {hasOption(optionsData, CdnOptionType.BROTLI) && ( + ( + + )} + /> + )} + {hasOption(optionsData, CdnOptionType.GEO_HEADERS) && ( + ( + + )} + /> + )} + {hasOption(optionsData, CdnOptionType.PREFETCH) && ( + ( + + + + )} + /> + )} + {hasOption(optionsData, CdnOptionType.MOBILE_REDIRECT) && ( + ( + + )} + /> + )} + {controlValues?.mobileRedirect && ( + <> + ( + field.onChange(e.target.value)} + > + + + + )} + /> + ( + field.onChange(e.target.value)} + /> + )} + /> + + )} + + ); +}; diff --git a/packages/manager/apps/web-hosting/src/pages/dashboard/multisite/cdn/component/OptionSecurity.tsx b/packages/manager/apps/web-hosting/src/pages/dashboard/multisite/cdn/component/OptionSecurity.tsx new file mode 100644 index 000000000000..9eac899bea8a --- /dev/null +++ b/packages/manager/apps/web-hosting/src/pages/dashboard/multisite/cdn/component/OptionSecurity.tsx @@ -0,0 +1,212 @@ +import React from 'react'; + +import { useNavigate, useParams } from 'react-router-dom'; + +import { Control, Controller } from 'react-hook-form'; +import { useTranslation } from 'react-i18next'; + +import { + ODS_BUTTON_COLOR, + ODS_BUTTON_VARIANT, + ODS_MESSAGE_COLOR, + ODS_TEXT_PRESET, +} from '@ovhcloud/ods-components'; +import { + OdsButton, + OdsInput, + OdsMessage, + OdsSelect, + OdsText, +} from '@ovhcloud/ods-components/react'; + +import { CdnFormValues, CdnOption } from '@/data/types/product/cdn'; +import { subRoutes, urls } from '@/routes/routes.constants'; +import { + SHARED_CDN_SETTINGS_RULE_FACTOR_DAY, + SHARED_CDN_SETTINGS_RULE_FACTOR_MONTH, + SHARED_CDN_SETTINGS_RULE_FACTOR_SECOND, +} from '@/utils'; +import { hasSecurityOption } from '@/utils/cdn'; + +import { ToggleCard } from './CdnToogleCard'; + +interface OptionSecurityProps { + controlValues: CdnFormValues; + control: Control; + optionsData: CdnOption[]; +} + +export const OptionSecurity: React.FC = ({ + controlValues, + control, + optionsData, +}) => { + const { t } = useTranslation(['dashboard']); + const { serviceName, domain } = useParams(); + const navigate = useNavigate(); + + if (!hasSecurityOption(optionsData)) return null; + return ( +
+ + {t('cdn_shared_option_category_security')} + + + ( + + )} + /> + {controlValues?.cors && ( + + navigate( + urls.cdnCorsResourceSharing + .replace(subRoutes.serviceName, serviceName) + .replace(subRoutes.domain, domain), + ) + } + /> + )} + ( + + {field.value && ( + + {t('cdn_ssl_required_warning')} + + )} + + )} + /> + {controlValues?.httpsRedirect && ( + ( + field.onChange(e.target.value)} + > + + + + )} + /> + )} + ( + + {field.value && ( + + {t('cdn_ssl_required_warning')} + + )} + + )} + /> + {controlValues?.hsts && ( + <> +
+ ( + field.onChange(e.target.value)} + /> + )} + /> + ( + field.onChange(e.target.value)} + > + + + + + )} + /> +
+ + )} + ( + + )} + /> + ( + + )} + /> +
+ ); +}; diff --git a/packages/manager/apps/web-hosting/src/routes/pages/cdn.ts b/packages/manager/apps/web-hosting/src/routes/pages/cdn.ts index 808bacef2ba0..edc443e02a24 100644 --- a/packages/manager/apps/web-hosting/src/routes/pages/cdn.ts +++ b/packages/manager/apps/web-hosting/src/routes/pages/cdn.ts @@ -6,3 +6,18 @@ export const ModifyCdnPage = React.lazy( export const PurgeCdnModal = React.lazy( () => import('@/pages/dashboard/multisite/cdn/PurgeCdn.modal'), ); +export const AdvancedFlushCdnModal = React.lazy( + () => import('@/pages/dashboard/multisite/cdn/AdvancedFlushCdn.modal'), +); +export const CdnCacheRuleModal = React.lazy( + () => import('@/pages/dashboard/multisite/cdn/CdnCacheRule.modal'), +); +export const CdnEditUrlsModal = React.lazy( + () => import('@/pages/dashboard/multisite/cdn/CdnEditUrls.modal'), +); +export const CdnCorsResourceSharingModal = React.lazy( + () => import('@/pages/dashboard/multisite/cdn/CdnCorsResourceSharing.modal'), +); +export const CdnConfirmationModal = React.lazy( + () => import('@/pages/dashboard/multisite/cdn/CdnConfirmation.modal'), +); diff --git a/packages/manager/apps/web-hosting/src/routes/routes.constants.ts b/packages/manager/apps/web-hosting/src/routes/routes.constants.ts index 638c4fc7e14b..d48789526c50 100644 --- a/packages/manager/apps/web-hosting/src/routes/routes.constants.ts +++ b/packages/manager/apps/web-hosting/src/routes/routes.constants.ts @@ -59,6 +59,11 @@ export const urls = { // CDN modifyCdn: `/${subRoutes.serviceName}/multisite/${subRoutes.domain}/modify-cdn`, purgeCdn: `/${subRoutes.serviceName}/multisite/${subRoutes.domain}/purge-cdn`, + advancedFlushCdn: `/${subRoutes.serviceName}/multisite/${subRoutes.domain}/advanced-flush-cdn`, + cdnCacheRule: `/${subRoutes.serviceName}/multisite/${subRoutes.domain}/cdn-cache-rule`, + cdnEditUrls: `/${subRoutes.serviceName}/multisite/${subRoutes.domain}/cdn-edit-urls`, + cdnCorsResourceSharing: `/${subRoutes.serviceName}/multisite/${subRoutes.domain}/cdn-cors-resource-sharing`, + cdnConfirmation: `/${subRoutes.serviceName}/multisite/${subRoutes.domain}/cdn-confirmation`, // MODULE addModule: `/${subRoutes.serviceName}/multisite/add-module`, diff --git a/packages/manager/apps/web-hosting/src/routes/routes.tsx b/packages/manager/apps/web-hosting/src/routes/routes.tsx index d59de16fe711..c4ffb45f07e4 100644 --- a/packages/manager/apps/web-hosting/src/routes/routes.tsx +++ b/packages/manager/apps/web-hosting/src/routes/routes.tsx @@ -14,7 +14,12 @@ import { ADD_DOMAIN, ADD_MODULE, ADD_WEBSITE, + ADVANCED_FLUSH_CDN, ASSOCIATE_GIT, + CDN_CACHE_RULE, + CDN_CONFIRMATION, + CDN_CORS_RESOURCE_SHARING, + CDN_EDIT_URLS, CONFIGURE_GIT, CREATE, DASHBOARD, @@ -46,7 +51,15 @@ import { WORDPRESS_MANAGED, WORDPRESS_MANAGED_SERVICE, } from '../utils/tracking.constants'; -import { ModifyCdnPage, PurgeCdnModal } from './pages/cdn'; +import { + AdvancedFlushCdnModal, + CdnCacheRuleModal, + CdnConfirmationModal, + CdnCorsResourceSharingModal, + CdnEditUrlsModal, + ModifyCdnPage, + PurgeCdnModal, +} from './pages/cdn'; import { DashboardLayout, MultisitePage, @@ -500,6 +513,61 @@ export default ( }, }} /> + + + + + { + return domainOptions?.find((opt) => opt.type === type)?.enabled || false; +}; + +export const hasOption = (domainOptions: CdnOption[], type: CdnOptionType) => { + return domainOptions?.some((opt) => opt.type === type); +}; + +export const findOption = (domainOptions: CdnOption[], type: CdnOptionType) => { + return domainOptions?.find((opt) => opt.type === type); +}; + +export const getPrewarmQuotaPercentage = (domainOptions: CdnOption[]) => { + const prewarmData = findOption(domainOptions, CdnOptionType.PREWARM); + return ((prewarmData?.extra?.usage || 0) / prewarmData?.extra?.quota) * 100; +}; + +export const getQuotaUsage = (domainOptions: CdnOption[]) => { + function bytesFR(value = 0, { precision = 2 } = {}) { + if (!Number.isFinite(value) || value <= 0) return '0 o'; + const base = 1024; + const units = ['o', 'Ko', 'Mo', 'Go', 'To', 'Po']; + const i = Math.min(Math.floor(Math.log(value) / Math.log(base)), units.length - 1); + + const num = value / base ** i; + + const formatted = new Intl.NumberFormat('fr-FR', { + minimumFractionDigits: 0, + maximumFractionDigits: precision, + }).format(num); + + return `${formatted} ${units[i]}`; + } + + const prewarmData = findOption(domainOptions, CdnOptionType.PREWARM); + + const { usage = 0, quota } = prewarmData?.extra; + const convertUsage = bytesFR(usage); + const convertQuota = bytesFR(quota); + const totalUsage = ((usage / quota) * 100).toFixed(2); + + return `${convertUsage} / ${convertQuota} (${totalUsage}%)`; +}; + +export const convertToUnitTime = (ttl: number, t: (key: string) => string) => { + if (ttl % SHARED_CDN_SETTINGS_RULE_FACTOR_DAY === 0) { + return { + timeValue: ttl / SHARED_CDN_SETTINGS_RULE_FACTOR_DAY, + timeUnit: t('cdn_shared_modal_add_rule_field_time_to_live_unit_days'), + }; + } + if (ttl % SHARED_CDN_SETTINGS_RULE_FACTOR_HOUR === 0) + return { + timeValue: ttl / SHARED_CDN_SETTINGS_RULE_FACTOR_HOUR, + timeUnit: t('cdn_shared_modal_add_rule_field_time_to_live_unit_hours'), + }; + return { + timeValue: ttl / SHARED_CDN_SETTINGS_RULE_FACTOR_MINUTE, + timeUnit: t('cdn_shared_modal_add_rule_field_time_to_live_unit_minutes'), + }; +}; + +export const convertToHstsUnit = (ttl: number) => { + if (ttl % SHARED_CDN_SETTINGS_RULE_FACTOR_SECOND === 0) { + return { + age: ttl / SHARED_CDN_SETTINGS_RULE_FACTOR_SECOND, + unit: SHARED_CDN_SETTINGS_RULE_FACTOR_SECOND, + }; + } + if (ttl % SHARED_CDN_SETTINGS_RULE_FACTOR_DAY === 0) + return { + age: ttl / SHARED_CDN_SETTINGS_RULE_FACTOR_DAY, + unit: SHARED_CDN_SETTINGS_RULE_FACTOR_DAY, + }; + return { + age: ttl / SHARED_CDN_SETTINGS_RULE_FACTOR_MONTH, + unit: SHARED_CDN_SETTINGS_RULE_FACTOR_MONTH, + }; +}; + +export const convertToTtl = (timeValue: number, timeUnit: string, t: (key: string) => string) => { + switch (timeUnit) { + case t('cdn_shared_modal_add_rule_field_time_to_live_unit_days'): + return timeValue * SHARED_CDN_SETTINGS_RULE_FACTOR_DAY; + case t('cdn_shared_modal_add_rule_field_time_to_live_unit_hours'): + return timeValue * SHARED_CDN_SETTINGS_RULE_FACTOR_HOUR; + case t('cdn_shared_modal_add_rule_field_time_to_live_unit_minutes'): + return timeValue * SHARED_CDN_SETTINGS_RULE_FACTOR_MINUTE; + default: + return 0; + } +}; + +export const cdnFormDefaultValues = (optionsData: CdnOption[]): CdnFormValues => { + const hstsUnit = convertToHstsUnit(findOption(optionsData, CdnOptionType.HSTS)?.config?.ttl); + return { + brotli: isOptionEnabled(optionsData, CdnOptionType.BROTLI), + geoHeaders: isOptionEnabled(optionsData, CdnOptionType.GEO_HEADERS), + prefetch: isOptionEnabled(optionsData, CdnOptionType.PREFETCH), + mobileRedirect: isOptionEnabled(optionsData, CdnOptionType.MOBILE_REDIRECT), + devmode: isOptionEnabled(optionsData, CdnOptionType.DEVMODE), + querystring: isOptionEnabled(optionsData, CdnOptionType.QUERYSTRING), + prewarm: isOptionEnabled(optionsData, CdnOptionType.PREWARM), + cors: isOptionEnabled(optionsData, CdnOptionType.CORS), + httpsRedirect: isOptionEnabled(optionsData, CdnOptionType.HTTPS_REDIRECT), + hsts: isOptionEnabled(optionsData, CdnOptionType.HSTS), + mixedContent: isOptionEnabled(optionsData, CdnOptionType.MIXED_CONTENT), + waf: isOptionEnabled(optionsData, CdnOptionType.WAF), + hstsAge: hstsUnit?.age || 0, + hstUnit: hstsUnit?.unit || 0, + mobileRedirectType: findOption(optionsData, CdnOptionType.MOBILE_REDIRECT)?.config?.followUri + ? SHARED_CDN_OPTIONS.MOBILE_REDIRECT.STILL_URL + : SHARED_CDN_OPTIONS.MOBILE_REDIRECT.KEEP_URL, + mobileRedirectUrl: findOption(optionsData, CdnOptionType.MOBILE_REDIRECT)?.config?.destination, + corsResources: findOption(optionsData, CdnOptionType.CORS)?.config?.resources, + premwarmResources: findOption(optionsData, CdnOptionType.PREWARM)?.config?.resources, + querytringParam: findOption(optionsData, CdnOptionType.QUERYSTRING)?.config?.queryParameters, + httpsRedirectCode: findOption(optionsData, CdnOptionType.HTTPS_REDIRECT)?.config?.statusCode, + }; +}; + +export const hasSecurityOption = (optionsData: CdnOption[]) => { + return [ + CdnOptionType.CORS, + CdnOptionType.HTTPS_REDIRECT, + CdnOptionType.HSTS, + CdnOptionType.MIXED_CONTENT, + CdnOptionType.WAF, + ].some((key) => optionsData?.find((opt) => opt.type === key)); +}; diff --git a/packages/manager/apps/web-hosting/src/utils/index.ts b/packages/manager/apps/web-hosting/src/utils/index.ts index 90efbce1a88f..a8df32319ec0 100644 --- a/packages/manager/apps/web-hosting/src/utils/index.ts +++ b/packages/manager/apps/web-hosting/src/utils/index.ts @@ -7,3 +7,9 @@ export * from './getStatusColor'; export const APIV2_MAX_PAGESIZE = 500; export const DATAGRID_REFRESH_INTERVAL = 5_000; export const DATAGRID_REFRESH_ON_MOUNT = 'always'; + +export const SHARED_CDN_SETTINGS_RULE_FACTOR_DAY = 86400; +export const SHARED_CDN_SETTINGS_RULE_FACTOR_HOUR = 3600; +export const SHARED_CDN_SETTINGS_RULE_FACTOR_MINUTE = 60; +export const SHARED_CDN_SETTINGS_RULE_FACTOR_MONTH = 24 * 3600 * 30; +export const SHARED_CDN_SETTINGS_RULE_FACTOR_SECOND = 1; diff --git a/packages/manager/apps/web-hosting/src/utils/test.setup.tsx b/packages/manager/apps/web-hosting/src/utils/test.setup.tsx index 246a3b89af35..203c446459a8 100644 --- a/packages/manager/apps/web-hosting/src/utils/test.setup.tsx +++ b/packages/manager/apps/web-hosting/src/utils/test.setup.tsx @@ -15,7 +15,7 @@ import { webHostingMock, websitesMocks, } from '@/data/__mocks__'; -import { cdnPropertiesMock } from '@/data/__mocks__/cdn'; +import { cdnOptionMock, cdnPropertiesMock, serviceNameCdnMock } from '@/data/__mocks__/cdn'; import { managedWordpressRerefenceAvailableLanguageMock } from '@/data/__mocks__/managedWordpress/language'; import { managedWordpressResourceDetailsMock, @@ -199,6 +199,21 @@ vi.mock('@/data/hooks/cdn', () => ({ flushCdn: vi.fn(), })); +vi.mock('@/data/hooks/cdn/useCdn', async (importActual) => { + const actual = await importActual(); + return { + ...actual, + useGetServiceNameCdn: vi.fn(() => ({ + data: serviceNameCdnMock, + isSuccess: true, + })), + useGetCdnOption: vi.fn(() => ({ + data: cdnOptionMock, + isSuccess: true, + })), + }; +}); + vi.mock('@/data/hooks/webHostingDashboard/useWebHostingDashboard', () => ({ useCreateAttachedDomainsService: vi.fn(), useGetAddDomainExisting: vi.fn(), diff --git a/packages/manager/apps/web-hosting/src/utils/tracking.constants.ts b/packages/manager/apps/web-hosting/src/utils/tracking.constants.ts index 9d836ee419ef..1993323eed05 100644 --- a/packages/manager/apps/web-hosting/src/utils/tracking.constants.ts +++ b/packages/manager/apps/web-hosting/src/utils/tracking.constants.ts @@ -65,6 +65,11 @@ export const LAST_DEPLOYEMENT_GIT = 'last-deployment-git'; // cdn export const MODIFY_CDN = 'modify-cdn'; export const PURGE_CDN = 'purge-cdn'; +export const ADVANCED_FLUSH_CDN = 'advanced-flush-cdn'; +export const CDN_CACHE_RULE = 'cdn-cache-rule'; +export const CDN_EDIT_URLS = 'cdn-edit-urls'; +export const CDN_CORS_RESOURCE_SHARING = 'cdn-cors-resource-sharing'; +export const CDN_CONFIRMATION = 'cdn-confirmation'; // module export const ADD_MODULE = 'add-module';