diff --git a/Localize/locales/en/plugin-translation.json b/Localize/locales/en/plugin-translation.json index 5664403b9..1fb219e83 100644 --- a/Localize/locales/en/plugin-translation.json +++ b/Localize/locales/en/plugin-translation.json @@ -11,6 +11,8 @@ "{{count}} failed._other": "{{count}} failed.", "{{count}} min_one": "{{count}} min", "{{count}} min_other": "{{count}} min", + "{{count}} nodes_one": "{{count}} node", + "{{count}} nodes_other": "{{count}} nodes", "{{count}} selected_one": "{{count}} selected", "{{count}} selected_other": "{{count}} selected", "{{days}}d ago": "{{days}}d ago", @@ -659,6 +661,7 @@ "Target CPU utilization": "Target CPU utilization", "Target CPU Utilization (%)": "Target CPU Utilization (%)", "Target port": "Target port", + "Tenant": "Tenant", "The agent has created the deployment PR. Review the generated files and merge to start the deployment pipeline.": "The agent has created the deployment PR. Review the generated files and merge to start the deployment pipeline.", "The agent is creating the setup PR to enable the Copilot agent.": "The agent is creating the setup PR to enable the Copilot agent.", "The AKS Desktop GitHub App must be installed on {{repoName}} to continue.": "The AKS Desktop GitHub App must be installed on {{repoName}} to continue.", diff --git a/plugins/aks-desktop/locales/cs/translation.json b/plugins/aks-desktop/locales/cs/translation.json index b82663619..190913d88 100644 --- a/plugins/aks-desktop/locales/cs/translation.json +++ b/plugins/aks-desktop/locales/cs/translation.json @@ -691,5 +691,8 @@ "{{minutes}} min ago": "před {{minutes}} min.", "{{hours}}h ago": "Před {{hours}} h", "{{days}}d ago": "Před {{days}} d", - "{{weeks}}w ago": "před {{weeks}} týd." + "{{weeks}}w ago": "před {{weeks}} týd.", + "Tenant": "Tenant", + "{{count}} nodes_one": "{{count}} node", + "{{count}} nodes_other": "{{count}} nodes" } diff --git a/plugins/aks-desktop/locales/de/translation.json b/plugins/aks-desktop/locales/de/translation.json index 3f0b5bae8..85443f09d 100644 --- a/plugins/aks-desktop/locales/de/translation.json +++ b/plugins/aks-desktop/locales/de/translation.json @@ -669,5 +669,8 @@ "{{minutes}} min ago": "Vor {{minutes}} Min.", "{{hours}}h ago": "Vor {{hours}} Stunde(n)", "{{days}}d ago": "Vor {{days}} Tag(en)", - "{{weeks}}w ago": "Vor {{weeks}} Woche(n)" + "{{weeks}}w ago": "Vor {{weeks}} Woche(n)", + "Tenant": "Tenant", + "{{count}} nodes_one": "{{count}} node", + "{{count}} nodes_other": "{{count}} nodes" } diff --git a/plugins/aks-desktop/locales/en/translation.json b/plugins/aks-desktop/locales/en/translation.json index 03cfa8fdb..827864a98 100644 --- a/plugins/aks-desktop/locales/en/translation.json +++ b/plugins/aks-desktop/locales/en/translation.json @@ -669,5 +669,8 @@ "{{minutes}} min ago": "{{minutes}} min ago", "{{hours}}h ago": "{{hours}}h ago", "{{days}}d ago": "{{days}}d ago", - "{{weeks}}w ago": "{{weeks}}w ago" + "{{weeks}}w ago": "{{weeks}}w ago", + "Tenant": "Tenant", + "{{count}} nodes_one": "{{count}} node", + "{{count}} nodes_other": "{{count}} nodes" } diff --git a/plugins/aks-desktop/locales/es/translation.json b/plugins/aks-desktop/locales/es/translation.json index 06c49a408..c277961cb 100644 --- a/plugins/aks-desktop/locales/es/translation.json +++ b/plugins/aks-desktop/locales/es/translation.json @@ -680,5 +680,8 @@ "{{minutes}} min ago": "hace {{minutes}}\u00a0min", "{{hours}}h ago": "hace {{hours}}\u00a0h", "{{days}}d ago": "hace {{days}}\u00a0d", - "{{weeks}}w ago": "hace {{weeks}} sem." + "{{weeks}}w ago": "hace {{weeks}} sem.", + "Tenant": "Tenant", + "{{count}} nodes_one": "{{count}} node", + "{{count}} nodes_other": "{{count}} nodes" } diff --git a/plugins/aks-desktop/locales/fr/translation.json b/plugins/aks-desktop/locales/fr/translation.json index 13b245251..799c6f421 100644 --- a/plugins/aks-desktop/locales/fr/translation.json +++ b/plugins/aks-desktop/locales/fr/translation.json @@ -680,5 +680,8 @@ "{{minutes}} min ago": "Il y a {{minutes}}\u00a0min", "{{hours}}h ago": "Il y a {{hours}}\u00a0heures", "{{days}}d ago": "Il y a {{days}}\u00a0jours", - "{{weeks}}w ago": "Il y a {{weeks}}\u00a0semaines" + "{{weeks}}w ago": "Il y a {{weeks}}\u00a0semaines", + "Tenant": "Tenant", + "{{count}} nodes_one": "{{count}} node", + "{{count}} nodes_other": "{{count}} nodes" } diff --git a/plugins/aks-desktop/locales/hu/translation.json b/plugins/aks-desktop/locales/hu/translation.json index 2e071d4a8..9e487798e 100644 --- a/plugins/aks-desktop/locales/hu/translation.json +++ b/plugins/aks-desktop/locales/hu/translation.json @@ -669,5 +669,8 @@ "{{minutes}} min ago": "{{minutes}} perce", "{{hours}}h ago": "{{hours}} órája", "{{days}}d ago": "{{days}} napja", - "{{weeks}}w ago": "{{weeks}} hete" + "{{weeks}}w ago": "{{weeks}} hete", + "Tenant": "Tenant", + "{{count}} nodes_one": "{{count}} node", + "{{count}} nodes_other": "{{count}} nodes" } diff --git a/plugins/aks-desktop/locales/id/translation.json b/plugins/aks-desktop/locales/id/translation.json index 1f450ea29..2ef74c28d 100644 --- a/plugins/aks-desktop/locales/id/translation.json +++ b/plugins/aks-desktop/locales/id/translation.json @@ -669,5 +669,8 @@ "Merging cluster {{clusterName}} ({{count}} namespace(s))_one": "Menggabungkan kluster {{clusterName}} ({{count}} namespace)", "Resources to be deployed ({{count}} object)_one": "Sumber daya yang akan disebarkan ({{count}} objek)", "Successfully merged {{count}} cluster(s)_one": "Berhasil menggabungkan {{count}} kluster", - "with {{count}} project(s)_one": "dengan {{count}} proyek" + "with {{count}} project(s)_one": "dengan {{count}} proyek", + "Tenant": "Tenant", + "{{count}} nodes_one": "{{count}} node", + "{{count}} nodes_other": "{{count}} nodes" } diff --git a/plugins/aks-desktop/locales/it/translation.json b/plugins/aks-desktop/locales/it/translation.json index 08c6fbd78..d28ccfc81 100644 --- a/plugins/aks-desktop/locales/it/translation.json +++ b/plugins/aks-desktop/locales/it/translation.json @@ -680,5 +680,8 @@ "{{minutes}} min ago": "{{minutes}} minuto/i fa", "{{hours}}h ago": "{{hours}} ora/e fa", "{{days}}d ago": "{{days}} giorno/i fa", - "{{weeks}}w ago": "{{weeks}} settimana/e fa" + "{{weeks}}w ago": "{{weeks}} settimana/e fa", + "Tenant": "Tenant", + "{{count}} nodes_one": "{{count}} node", + "{{count}} nodes_other": "{{count}} nodes" } diff --git a/plugins/aks-desktop/locales/ja/translation.json b/plugins/aks-desktop/locales/ja/translation.json index def084dd7..f6bf674dc 100644 --- a/plugins/aks-desktop/locales/ja/translation.json +++ b/plugins/aks-desktop/locales/ja/translation.json @@ -669,5 +669,8 @@ "Merging cluster {{clusterName}} ({{count}} namespace(s))_one": "クラスター {{clusterName}} をマージしています ({{count}} 個の名前空間)", "Resources to be deployed ({{count}} object)_one": "デプロイするリソース ({{count}} 個のオブジェクト)", "Successfully merged {{count}} cluster(s)_one": "{{count}} 個のクラスターが正常にマージされました", - "with {{count}} project(s)_one": "{{count}} 個のプロジェクトを含む" + "with {{count}} project(s)_one": "{{count}} 個のプロジェクトを含む", + "Tenant": "Tenant", + "{{count}} nodes_one": "{{count}} node", + "{{count}} nodes_other": "{{count}} nodes" } diff --git a/plugins/aks-desktop/locales/ko/translation.json b/plugins/aks-desktop/locales/ko/translation.json index 9d8e2d4b7..1f65d7099 100644 --- a/plugins/aks-desktop/locales/ko/translation.json +++ b/plugins/aks-desktop/locales/ko/translation.json @@ -669,5 +669,8 @@ "Merging cluster {{clusterName}} ({{count}} namespace(s))_one": "{{clusterName}} 클러스터 병합(네임스페이스 {{count}}개)", "Resources to be deployed ({{count}} object)_one": "배포할 리소스({{count}}개 개체)", "Successfully merged {{count}} cluster(s)_one": "클러스터를 {{count}}개를 병합함", - "with {{count}} project(s)_one": "프로젝트 {{count}}개 포함" + "with {{count}} project(s)_one": "프로젝트 {{count}}개 포함", + "Tenant": "Tenant", + "{{count}} nodes_one": "{{count}} node", + "{{count}} nodes_other": "{{count}} nodes" } diff --git a/plugins/aks-desktop/locales/nl/translation.json b/plugins/aks-desktop/locales/nl/translation.json index 02325875b..e3e921950 100644 --- a/plugins/aks-desktop/locales/nl/translation.json +++ b/plugins/aks-desktop/locales/nl/translation.json @@ -669,5 +669,8 @@ "{{minutes}} min ago": "{{minutes}} min. geleden", "{{hours}}h ago": "{{hours}}u geleden", "{{days}}d ago": "{{days}}d geleden", - "{{weeks}}w ago": "{{weeks}}w geleden" + "{{weeks}}w ago": "{{weeks}}w geleden", + "Tenant": "Tenant", + "{{count}} nodes_one": "{{count}} node", + "{{count}} nodes_other": "{{count}} nodes" } diff --git a/plugins/aks-desktop/locales/pl/translation.json b/plugins/aks-desktop/locales/pl/translation.json index 6fc17c1a4..2001fdcae 100644 --- a/plugins/aks-desktop/locales/pl/translation.json +++ b/plugins/aks-desktop/locales/pl/translation.json @@ -691,5 +691,8 @@ "{{minutes}} min ago": "{{minutes}} min temu", "{{hours}}h ago": "{{hours}} godz. temu", "{{days}}d ago": "{{days}} dni temu", - "{{weeks}}w ago": "{{weeks}} tyg. temu" + "{{weeks}}w ago": "{{weeks}} tyg. temu", + "Tenant": "Tenant", + "{{count}} nodes_one": "{{count}} node", + "{{count}} nodes_other": "{{count}} nodes" } diff --git a/plugins/aks-desktop/locales/pt-BR/translation.json b/plugins/aks-desktop/locales/pt-BR/translation.json index becf1682b..9e64f7bce 100644 --- a/plugins/aks-desktop/locales/pt-BR/translation.json +++ b/plugins/aks-desktop/locales/pt-BR/translation.json @@ -680,5 +680,8 @@ "{{minutes}} min ago": "há {{minutes}} min", "{{hours}}h ago": "Há {{hours}} hora(s)", "{{days}}d ago": "{{days}} dia(s) atrás", - "{{weeks}}w ago": "Há {{weeks}} semanas" + "{{weeks}}w ago": "Há {{weeks}} semanas", + "Tenant": "Tenant", + "{{count}} nodes_one": "{{count}} node", + "{{count}} nodes_other": "{{count}} nodes" } diff --git a/plugins/aks-desktop/locales/pt-PT/translation.json b/plugins/aks-desktop/locales/pt-PT/translation.json index 2d7867309..398909224 100644 --- a/plugins/aks-desktop/locales/pt-PT/translation.json +++ b/plugins/aks-desktop/locales/pt-PT/translation.json @@ -680,5 +680,8 @@ "{{minutes}} min ago": "Há {{minutes}} min", "{{hours}}h ago": "Há {{hours}}\u00a0h", "{{days}}d ago": "Há {{days}} dia(s)", - "{{weeks}}w ago": "Há {{weeks}} semanas" + "{{weeks}}w ago": "Há {{weeks}} semanas", + "Tenant": "Tenant", + "{{count}} nodes_one": "{{count}} node", + "{{count}} nodes_other": "{{count}} nodes" } diff --git a/plugins/aks-desktop/locales/ru/translation.json b/plugins/aks-desktop/locales/ru/translation.json index f0e82b403..2c2862b55 100644 --- a/plugins/aks-desktop/locales/ru/translation.json +++ b/plugins/aks-desktop/locales/ru/translation.json @@ -691,5 +691,8 @@ "{{minutes}} min ago": "{{minutes}} мин. назад", "{{hours}}h ago": "{{hours}} ч назад", "{{days}}d ago": "{{days}}\u00a0дн. назад", - "{{weeks}}w ago": "{{weeks}} нед. назад" + "{{weeks}}w ago": "{{weeks}} нед. назад", + "Tenant": "Tenant", + "{{count}} nodes_one": "{{count}} node", + "{{count}} nodes_other": "{{count}} nodes" } diff --git a/plugins/aks-desktop/locales/sv/translation.json b/plugins/aks-desktop/locales/sv/translation.json index 8890451c4..286d5e94a 100644 --- a/plugins/aks-desktop/locales/sv/translation.json +++ b/plugins/aks-desktop/locales/sv/translation.json @@ -669,5 +669,8 @@ "{{minutes}} min ago": "{{minutes}} min sedan", "{{hours}}h ago": "{{hours}}tim sedan", "{{days}}d ago": "{{days}} d sedan", - "{{weeks}}w ago": "{{weeks}} v sedan" + "{{weeks}}w ago": "{{weeks}} v sedan", + "Tenant": "Tenant", + "{{count}} nodes_one": "{{count}} node", + "{{count}} nodes_other": "{{count}} nodes" } diff --git a/plugins/aks-desktop/locales/tr/translation.json b/plugins/aks-desktop/locales/tr/translation.json index 5a770e712..d0854a92f 100644 --- a/plugins/aks-desktop/locales/tr/translation.json +++ b/plugins/aks-desktop/locales/tr/translation.json @@ -669,5 +669,8 @@ "{{minutes}} min ago": "{{minutes}} dakika önce", "{{hours}}h ago": "{{hours}} saat önce", "{{days}}d ago": "{{days}} gün önce", - "{{weeks}}w ago": "{{weeks}} hafta önce" + "{{weeks}}w ago": "{{weeks}} hafta önce", + "Tenant": "Tenant", + "{{count}} nodes_one": "{{count}} node", + "{{count}} nodes_other": "{{count}} nodes" } diff --git a/plugins/aks-desktop/locales/zh-Hans/translation.json b/plugins/aks-desktop/locales/zh-Hans/translation.json index 79100194a..061645650 100644 --- a/plugins/aks-desktop/locales/zh-Hans/translation.json +++ b/plugins/aks-desktop/locales/zh-Hans/translation.json @@ -669,5 +669,8 @@ "Merging cluster {{clusterName}} ({{count}} namespace(s))_one": "合并群集“{{clusterName}}”({{count}} 个命名空间)", "Resources to be deployed ({{count}} object)_one": "要部署的资源({{count}} 个对象)", "Successfully merged {{count}} cluster(s)_one": "已成功合并 {{count}} 个群集", - "with {{count}} project(s)_one": "具有 {{count}} 个项目" + "with {{count}} project(s)_one": "具有 {{count}} 个项目", + "Tenant": "Tenant", + "{{count}} nodes_one": "{{count}} node", + "{{count}} nodes_other": "{{count}} nodes" } diff --git a/plugins/aks-desktop/locales/zh-Hant/translation.json b/plugins/aks-desktop/locales/zh-Hant/translation.json index a0ff1f7d6..528bb6b12 100644 --- a/plugins/aks-desktop/locales/zh-Hant/translation.json +++ b/plugins/aks-desktop/locales/zh-Hant/translation.json @@ -669,5 +669,8 @@ "Merging cluster {{clusterName}} ({{count}} namespace(s))_one": "正在合併叢集 {{clusterName}} ({{count}} 個命名空間)", "Resources to be deployed ({{count}} object)_one": "要部署的資源 ({{count}} 個物件)", "Successfully merged {{count}} cluster(s)_one": "已成功合併 {{count}} 個叢集", - "with {{count}} project(s)_one": "具有 {{count}} 個專案" + "with {{count}} project(s)_one": "具有 {{count}} 個專案", + "Tenant": "Tenant", + "{{count}} nodes_one": "{{count}} node", + "{{count}} nodes_other": "{{count}} nodes" } diff --git a/plugins/aks-desktop/src/components/CreateAKSProject/components/BasicsStep.stories.tsx b/plugins/aks-desktop/src/components/CreateAKSProject/components/BasicsStep.stories.tsx new file mode 100644 index 000000000..4a53c1c40 --- /dev/null +++ b/plugins/aks-desktop/src/components/CreateAKSProject/components/BasicsStep.stories.tsx @@ -0,0 +1,332 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the Apache 2.0. + +import { Meta, StoryFn } from '@storybook/react'; +import React from 'react'; +import { MemoryRouter } from 'react-router-dom'; +import type { BasicsStepProps } from '../types'; +import { BasicsStep } from './BasicsStep'; + +// --------------------------------------------------------------------------- +// Shared fixture data +// --------------------------------------------------------------------------- + +const BASE_FORM_DATA = { + projectName: 'azure-microservices-demo', + description: '', + subscription: '', + cluster: '', + resourceGroup: '', + ingress: 'AllowSameNamespace' as const, + egress: 'AllowAll' as const, + cpuRequest: 2000, + memoryRequest: 4096, + cpuLimit: 2000, + memoryLimit: 4096, + userAssignments: [], +}; + +const SUBSCRIPTIONS = [ + { + id: 'sub-123', + name: 'Production Subscription', + tenant: 'tenant-abc', + tenantName: 'Contoso', + status: 'Enabled', + }, + { + id: 'sub-456', + name: 'Dev / Test Subscription', + tenant: 'tenant-abc', + tenantName: 'Contoso', + status: 'Enabled', + }, +]; + +const CLUSTERS = [ + { + name: 'aks-prod-eastus', + location: 'eastus', + version: '1.28.5', + nodeCount: 3, + status: 'Succeeded', + resourceGroup: 'rg-prod', + powerState: 'Running', + }, + { + name: 'aks-staging-westus', + location: 'westus', + version: '1.27.9', + nodeCount: 2, + status: 'Succeeded', + resourceGroup: 'rg-staging', + powerState: 'Running', + }, +]; + +const BASE_PROPS: BasicsStepProps = { + formData: BASE_FORM_DATA, + onFormDataChange: () => {}, + validation: { isValid: true, errors: [], warnings: [] }, + loading: false, + error: null, + subscriptions: SUBSCRIPTIONS, + clusters: [], + loadingClusters: false, + clusterError: null, + totalClusterCount: null, + extensionStatus: { installed: true, installing: false, error: null, showSuccess: false }, + featureStatus: { + registered: true, + state: 'Registered', + registering: false, + error: null, + showSuccess: false, + }, + namespaceStatus: { exists: null, checking: false, error: null }, + clusterCapabilities: null, + capabilitiesLoading: false, + onInstallExtension: async () => {}, + onRegisterFeature: async () => {}, + onRetrySubscriptions: async () => {}, + onRetryClusters: async () => {}, + onRefreshCapabilities: () => {}, +}; + +// --------------------------------------------------------------------------- +// Meta +// --------------------------------------------------------------------------- + +export default { + title: 'CreateAKSProject/BasicsStep', + component: BasicsStep, + decorators: [ + (Story: any) => ( + + + + ), + ], +} as Meta; + +const Template: StoryFn = args => ; + +// --------------------------------------------------------------------------- +// Stories +// --------------------------------------------------------------------------- + +/** + * Initial state: subscriptions loaded, no subscription or cluster selected yet. + */ +export const Default = Template.bind({}); +Default.args = BASE_PROPS; + +/** + * Subscriptions are still loading — the Subscription dropdown is disabled with a spinner. + */ +export const SubscriptionsLoading = Template.bind({}); +SubscriptionsLoading.args = { + ...BASE_PROPS, + subscriptions: [], + loading: true, +}; + +/** + * Subscription selected, clusters are still loading. + */ +export const ClustersLoading = Template.bind({}); +ClustersLoading.args = { + ...BASE_PROPS, + formData: { ...BASE_FORM_DATA, subscription: 'sub-123' }, + loadingClusters: true, +}; + +/** + * Subscription selected and clusters loaded. 5 total in the subscription, + * only 2 have Entra ID auth — helper text shows the hidden-cluster count. + */ +export const HiddenClusters = Template.bind({}); +HiddenClusters.args = { + ...BASE_PROPS, + formData: { ...BASE_FORM_DATA, subscription: 'sub-123' }, + clusters: CLUSTERS, + totalClusterCount: 5, +}; + +/** + * Selected cluster is in a non-ready provisioning state. + * Shows the "Cluster Not Ready" warning banner with a Refresh button. + */ +export const ClusterNotReady = Template.bind({}); +ClusterNotReady.args = { + ...BASE_PROPS, + formData: { + ...BASE_FORM_DATA, + subscription: 'sub-123', + cluster: 'aks-prod-eastus', + resourceGroup: 'rg-prod', + }, + clusters: [{ ...CLUSTERS[0], status: 'Updating' }, CLUSTERS[1]], + totalClusterCount: 2, +}; + +/** + * AKS Preview extension is not installed. + * Shows the yellow warning banner with the "Install Extension" button. + */ +export const ExtensionNotInstalled = Template.bind({}); +ExtensionNotInstalled.args = { + ...BASE_PROPS, + formData: { ...BASE_FORM_DATA, subscription: 'sub-123' }, + extensionStatus: { installed: false, installing: false, error: null, showSuccess: false }, +}; + +/** + * AKS Preview extension is currently being installed. + * The "Install Extension" button shows a spinner and is disabled. + */ +export const ExtensionInstalling = Template.bind({}); +ExtensionInstalling.args = { + ...BASE_PROPS, + formData: { ...BASE_FORM_DATA, subscription: 'sub-123' }, + extensionStatus: { installed: false, installing: true, error: null, showSuccess: false }, +}; + +/** + * AKS Preview extension install succeeded. + * Shows the green success alert. + */ +export const ExtensionInstallSuccess = Template.bind({}); +ExtensionInstallSuccess.args = { + ...BASE_PROPS, + formData: { ...BASE_FORM_DATA, subscription: 'sub-123' }, + extensionStatus: { installed: true, installing: false, error: null, showSuccess: true }, +}; + +/** + * The `ManagedNamespacePreview` feature flag is not yet registered. + * Shows the red error panel with the "Register Feature" button. + */ +export const FeatureNotRegistered = Template.bind({}); +FeatureNotRegistered.args = { + ...BASE_PROPS, + formData: { ...BASE_FORM_DATA, subscription: 'sub-123' }, + featureStatus: { + registered: false, + state: 'NotRegistered', + registering: false, + error: null, + showSuccess: false, + }, +}; + +/** + * Feature registration is in progress. + * The "Register Feature" button shows a spinner and is disabled. + */ +export const FeatureRegistering = Template.bind({}); +FeatureRegistering.args = { + ...BASE_PROPS, + formData: { ...BASE_FORM_DATA, subscription: 'sub-123' }, + featureStatus: { + registered: false, + state: 'Registering', + registering: true, + error: null, + showSuccess: false, + }, +}; + +/** + * Project name collides with an existing namespace. + * The Project Name field shows an error state and a "name taken" message. + */ +export const NamespaceExists = Template.bind({}); +NamespaceExists.args = { + ...BASE_PROPS, + formData: { ...BASE_FORM_DATA, subscription: 'sub-123' }, + namespaceStatus: { exists: true, checking: false, error: null }, +}; + +/** + * Namespace availability check is in flight. + * The Project Name field shows "Checking..." helper text. + */ +export const NamespaceChecking = Template.bind({}); +NamespaceChecking.args = { + ...BASE_PROPS, + formData: { ...BASE_FORM_DATA, subscription: 'sub-123' }, + namespaceStatus: { exists: null, checking: true, error: null }, +}; + +/** + * Subscription list failed to load. + * Shows an error alert above the Subscription dropdown with a Retry button. + */ +export const SubscriptionError = Template.bind({}); +SubscriptionError.args = { + ...BASE_PROPS, + subscriptions: [], + error: 'Failed to load subscriptions: authorization failed', +}; + +/** + * Cluster list failed to load. + * Shows an error alert below the Cluster dropdown with a Retry button. + */ +export const ClusterError = Template.bind({}); +ClusterError.args = { + ...BASE_PROPS, + formData: { ...BASE_FORM_DATA, subscription: 'sub-123' }, + clusters: [], + clusterError: 'Failed to load clusters: network timeout', +}; + +/** + * Cluster capabilities are loading after a cluster is selected. + * Shows the "Checking cluster capabilities..." text. + */ +export const CapabilitiesLoading = Template.bind({}); +CapabilitiesLoading.args = { + ...BASE_PROPS, + formData: { + ...BASE_FORM_DATA, + subscription: 'sub-123', + cluster: 'aks-prod-eastus', + resourceGroup: 'rg-prod', + }, + clusters: CLUSTERS, + capabilitiesLoading: true, +}; + +/** + * Cluster has some addons disabled (Prometheus off, KEDA off, VPA on). + * Shows the {@link ClusterConfigurePanel} below the cluster field. + */ +export const ConfigurableAddons = Template.bind({}); +ConfigurableAddons.args = { + ...BASE_PROPS, + formData: { + ...BASE_FORM_DATA, + subscription: 'sub-123', + cluster: 'aks-prod-eastus', + resourceGroup: 'rg-prod', + }, + clusters: CLUSTERS, + clusterCapabilities: { prometheusEnabled: false, kedaEnabled: false, vpaEnabled: true }, +}; + +/** + * Validation field error on the project name field. + */ +export const ProjectNameValidationError = Template.bind({}); +ProjectNameValidationError.args = { + ...BASE_PROPS, + formData: { ...BASE_FORM_DATA, subscription: 'sub-123' }, + validation: { + isValid: false, + errors: [], + warnings: [], + fieldErrors: { projectName: ['Name must be 63 characters or fewer'] }, + }, +}; diff --git a/plugins/aks-desktop/src/components/CreateAKSProject/components/BasicsStep.tsx b/plugins/aks-desktop/src/components/CreateAKSProject/components/BasicsStep.tsx index cfd83f0ab..a49e4514c 100644 --- a/plugins/aks-desktop/src/components/CreateAKSProject/components/BasicsStep.tsx +++ b/plugins/aks-desktop/src/components/CreateAKSProject/components/BasicsStep.tsx @@ -2,7 +2,7 @@ // Licensed under the Apache 2.0. import { Icon } from '@iconify/react'; -import { K8s, useTranslation } from '@kinvolk/headlamp-plugin/lib'; +import { useTranslation } from '@kinvolk/headlamp-plugin/lib'; import { Alert, AlertTitle, @@ -12,188 +12,157 @@ import { FormControl, Typography, } from '@mui/material'; -import React, { useEffect, useRef, useState } from 'react'; -import { useAzureAuth } from '../../../hooks/useAzureAuth'; +import React from 'react'; import type { ClusterCapabilities } from '../../../types/ClusterCapabilities'; -import { registerAKSCluster } from '../../../utils/azure/aks'; import { FormField } from '../../shared/FormField'; +import { useBasicsStep } from '../hooks/useBasicsStep'; +import { useRegisterCluster } from '../hooks/useRegisterCluster'; import type { BasicsStepProps } from '../types'; import { ClusterConfigurePanel } from './ClusterConfigurePanel'; -import { SearchableSelect, SearchableSelectOption } from './SearchableSelect'; +import { SearchableSelect } from './SearchableSelect'; import { ValidationAlert } from './ValidationAlert'; -/** Set to `true` locally to enable verbose debug logging. Never enable in production. */ -const DEBUG = false; +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- -// Helper to check if there are addons that can be enabled post-creation +/** Returns `true` when there are addons that can still be enabled post-creation. */ const hasConfigurableAddons = (cap: ClusterCapabilities | null): boolean => { if (!cap) return false; return cap.prometheusEnabled !== true || cap.kedaEnabled !== true || cap.vpaEnabled !== true; }; -function getClusterHelperText( - t: (key: string, options?: Record) => string, - loadingClusters: boolean, - clusterCount: number, - totalClusterCount: number | null -): string { - if (loadingClusters) { - return t('Only clusters with Azure Entra ID authentication are shown.'); - } - const hiddenCount = - totalClusterCount !== null && totalClusterCount > clusterCount - ? totalClusterCount - clusterCount - : 0; - const hiddenSuffix = - hiddenCount > 0 - ? ` (${t('{{count}} cluster(s) hidden — no Azure Entra ID', { count: hiddenCount })})` - : ''; - if (clusterCount === 0) { - return `${t('No eligible clusters found in this subscription.')}${hiddenSuffix}`; - } - return `${t('{{count}} eligible cluster(s) found.', { count: clusterCount })}${hiddenSuffix}`; +// --------------------------------------------------------------------------- +// RegisterCluster sub-component (pure presentation) +// --------------------------------------------------------------------------- + +/** + * Props for {@link RegisterCluster}. + */ +interface RegisterClusterProps { + cluster: string; + resourceGroup: string; + subscription: string; + tenantId?: string; } /** - * Basics step component for project details and Azure resource selection + * Presentational component that prompts the user to register a cluster that + * is selected in the form but absent from the headlamp kubeconfig. + * + * All async logic lives in {@link useRegisterCluster}. */ -export const BasicsStep: React.FC = ({ - formData, - onFormDataChange, - validation, - loading = false, - error = null, - subscriptions, - clusters, - loadingClusters, - clusterError, - extensionStatus, - featureStatus, - namespaceStatus, - totalClusterCount, - clusterCapabilities, - capabilitiesLoading, - onInstallExtension, - onRegisterFeature, - onRetrySubscriptions, - onRetryClusters, - onRefreshCapabilities, -}) => { +function RegisterCluster({ cluster, resourceGroup, subscription, tenantId }: RegisterClusterProps) { const { t } = useTranslation(); - const headlampClusters = K8s.useClustersConf(); - const authStatus = useAzureAuth(); - - // Focus the Project Name input on mount (the wizard-level focus effect - // misses the initial render because AzureAuthGuard delays mounting). - // Only steal focus when nothing else is focused (activeElement is ). - const projectNameRef = useRef(null); - useEffect(() => { - if (document.activeElement?.tagName === 'BODY') { - projectNameRef.current?.focus(); - } - }, []); - - // Auto select default subscription - const autoSelected = useRef(false); - useEffect(() => { - if ( - autoSelected.current === false && - authStatus?.subscriptionId && - !formData.subscription && - subscriptions && - subscriptions.find(it => it.id === authStatus.subscriptionId) - ) { - autoSelected.current = true; - onFormDataChange({ subscription: authStatus.subscriptionId }); - } - }, [formData.subscription, authStatus?.subscriptionId, subscriptions]); - - const handleInputChange = (field: string, value: any) => { - onFormDataChange({ [field]: value }); - }; - - const handleClusterChange = (clusterName: string) => { - const selectedCluster = clusters.find(c => c.name === clusterName); - if (selectedCluster) { - onFormDataChange({ - cluster: clusterName, - resourceGroup: selectedCluster.resourceGroup, - }); - } - }; - - // helper to check for readiness - const isClusterNonReady = (cluster: any): boolean => { - const provisioningState = cluster.status?.toLowerCase() || ''; - const powerState = cluster.powerState?.toLowerCase() || ''; - - const nonReadyProvisioningStates = ['updating', 'upgrading', 'deleting', 'creating', 'failed']; - const nonReadyPowerStates = ['stopping', 'stopped', 'deallocating', 'deallocated']; - - return ( - nonReadyProvisioningStates.includes(provisioningState) || - nonReadyPowerStates.includes(powerState) - ); - }; + const { loading, error, success, handleRegister, clearError, clearSuccess } = useRegisterCluster( + cluster, + resourceGroup, + subscription, + tenantId + ); - // helper function to get cluster state message - const getClusterStateMessage = (cluster: any): string => { - const provisioningState = cluster.status?.toLowerCase() || ''; - const powerState = cluster.powerState?.toLowerCase() || ''; + return ( + + {/* Missing-cluster notice — hidden once registration succeeds */} + {!success && ( + + + {t('Selected cluster is missing from the kubeconfig. Register it before proceeding.')} + + + )} - if (provisioningState === 'updating' || provisioningState === 'upgrading') { - return t('Cluster is currently updating. Deployment may fail.'); - } - if (provisioningState === 'deleting') { - return t('Cluster is being deleted. Cannot deploy to this cluster.'); - } - if (provisioningState === 'creating') { - return t('Cluster is still being created. Please wait until creation completes.'); - } - if (provisioningState === 'failed') { - return t('Cluster is in a failed state. Please check Azure portal.'); - } - if (powerState === 'stopped' || powerState === 'stopping') { - return t('Cluster is stopped. Please start the cluster before deploying.'); - } - if (powerState === 'deallocated' || powerState === 'deallocating') { - return t('Cluster is deallocated. Please start the cluster before deploying.'); - } - return ''; - }; + {/* Registration error */} + {error && ( + + {error} + + )} - // Convert subscriptions to SearchableSelectOption format - const subscriptionOptions: SearchableSelectOption[] = subscriptions.map(sub => ({ - value: sub.id, - label: sub.name, - subtitle: `Tenant: ${sub.tenantName} - (${sub.tenant}) • Status: ${sub.status}`, - })); + {/* Registration success */} + {success && ( + + {success} + + )} - // Convert clusters to SearchableSelectOption format - const clusterOptions: SearchableSelectOption[] = clusters.map(cluster => ({ - value: cluster.name, - label: cluster.name, - subtitle: `${cluster.location} • ${cluster.version} • ${cluster.nodeCount} nodes • ${cluster.status}`, - })); + {/* Register button — hidden once registration succeeds */} + {!success && ( + + )} + + ); +} - const selectedCluster = - formData.cluster && clusters.find(cluster => cluster.name === formData.cluster); +// --------------------------------------------------------------------------- +// BasicsStep component (pure presentation) +// --------------------------------------------------------------------------- - const isClusterMissing = - selectedCluster && - Object.values(headlampClusters).find((it: any) => it.name === selectedCluster.name) === - undefined; +/** + * Basics step of the Create AKS Project wizard. + * + * Collects the project name, description, Azure subscription, and AKS cluster. + * Also surfaces pre-flight warnings and errors for the AKS Preview extension, + * the ManagedNamespacePreview feature flag, cluster readiness, cluster + * capabilities, and namespace name availability. + * + * All stateful logic (focus management, auto-select, option mapping, cluster + * state derivation) lives in {@link useBasicsStep}. The `RegisterCluster` + * sub-component's async flow lives in {@link useRegisterCluster}. + */ +export const BasicsStep: React.FC = props => { + const { t } = useTranslation(); + const { + formData, + validation, + loading = false, + error = null, + loadingClusters, + clusterError, + extensionStatus, + featureStatus, + namespaceStatus, + clusterCapabilities, + capabilitiesLoading, + onInstallExtension, + onRegisterFeature, + onRetrySubscriptions, + onRetryClusters, + onRefreshCapabilities, + } = props; + + const { + projectNameRef, + subscriptionOptions, + clusterOptions, + clusterHelperText, + selectedSubscription, + selectedCluster, + isClusterMissing, + nonReadyCluster, + handleInputChange, + handleClusterChange, + } = useBasicsStep(props); return ( - {error && ( - {}} // Will be handled by parent - /> - )} - {/* AKS Preview Extension Check */} + {error && {}} />} + + {/* AKS Preview Extension check */} {extensionStatus.installed === false && ( = ({ } /> )} + {extensionStatus.showSuccess && ( )} - {/* ManagedNamespacePreview Feature Check */} + + {/* ManagedNamespacePreview feature flag check */} {featureStatus.registered === false && ( = ({ } /> )} + {featureStatus.showSuccess && ( )} + {/* Project Name */} @@ -372,7 +345,7 @@ export const BasicsStep: React.FC = ({ handleClusterChange(value)} + onChange={handleClusterChange} options={clusterOptions} loading={loadingClusters} error={!!clusterError} @@ -389,61 +362,52 @@ export const BasicsStep: React.FC = ({ 'No clusters with Azure Entra ID authentication found for this subscription' )} showSearch - helperText={getClusterHelperText(t, loadingClusters, clusters.length, totalClusterCount)} + helperText={clusterHelperText} /> + {/* Register cluster if it's missing from the kubeconfig */} {formData.subscription && selectedCluster && isClusterMissing && ( s.id === formData.subscription)?.tenant} + tenantId={selectedSubscription?.tenant} /> )} - {/* This shows a warning if the cluster isn't in a ready state*/} - {formData.cluster && - clusters.length > 0 && - (() => { - const selectedCluster = clusters.find(c => c.name === formData.cluster); - if (selectedCluster && isClusterNonReady(selectedCluster)) { - const stateMessage = getClusterStateMessage(selectedCluster); - return ( - - - - {t('Cluster Not Ready')}: {stateMessage} - - - } - // refresh button to reload clusters - action={ - - } - /> + {/* Cluster readiness warning */} + {nonReadyCluster && ( + + + + {t('Cluster Not Ready')}: {nonReadyCluster.message} + - ); - } - return null; - })()} + } + action={ + + } + /> + + )} {/* Cluster capability warnings */} {validation.warnings.length > 0 && ( @@ -455,7 +419,8 @@ export const BasicsStep: React.FC = ({ ))} )} - {/* Cluster configure panel for enabling missing addons */} + + {/* Configure panel for enabling missing addons */} {formData.cluster && clusterCapabilities && hasConfigurableAddons(clusterCapabilities) && ( = ({ subscriptionId={formData.subscription} resourceGroup={formData.resourceGroup} clusterName={formData.cluster} - onConfigured={() => { - if (onRefreshCapabilities) { - onRefreshCapabilities(); - } - }} + onConfigured={() => onRefreshCapabilities?.()} /> )} + + {/* Capabilities loading indicator */} {capabilitiesLoading && formData.cluster && ( @@ -479,6 +442,7 @@ export const BasicsStep: React.FC = ({ )} + {/* Cluster fetch error */} {clusterError && ( = ({ ); }; - -function RegisterCluster({ - cluster, - resourceGroup, - subscription, - tenantId, -}: { - cluster: string; - resourceGroup: string; - subscription: string; - tenantId?: string; -}) { - const { t } = useTranslation(); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(); - const [success, setSuccess] = useState(); - - const handleRegister = async () => { - if (!cluster || !subscription) { - return; - } - - setLoading(true); - setError(undefined); - setSuccess(undefined); - - try { - // Register the cluster by running az aks get-credentials and setting up kubeconfig - if (DEBUG) console.debug('[AKS] Registering cluster...'); - const result = await registerAKSCluster( - subscription, - resourceGroup, - cluster, - undefined, - tenantId - ); - if (DEBUG) console.debug('[AKS] Register cluster result:', result.success); - if (!result.success) { - setError(result.message); - setLoading(false); - return; - } - - if (DEBUG) console.debug('[AKS] Cluster registered successfully.', result.message); - setSuccess(t("Cluster '{{cluster}}' successfully merged in kubeconfig", { cluster })); - setLoading(false); - } catch (err) { - console.error('Error registering AKS cluster:', err); - setError( - t('Failed to register cluster: {{message}}', { - message: err instanceof Error ? err.message : t('Unknown error'), - }) - ); - setLoading(false); - } - }; - - return ( - - {/* Show error alert for missing cluster when no success */} - {!success && ( - - - {t('Selected cluster is missing from the kubeconfig. Register it before proceeding.')} - - - )} - - {/* Show registration error if any */} - {error && ( - setError(undefined)}> - {error} - - )} - - {/* Show success message */} - {success && ( - setSuccess(undefined)}> - {success} - - )} - - {/* Hide button when success is shown */} - {!success && ( - - )} - - ); -} diff --git a/plugins/aks-desktop/src/components/CreateAKSProject/components/SearchableSelect.tsx b/plugins/aks-desktop/src/components/CreateAKSProject/components/SearchableSelect.tsx index b0a2169cb..2db84ff53 100644 --- a/plugins/aks-desktop/src/components/CreateAKSProject/components/SearchableSelect.tsx +++ b/plugins/aks-desktop/src/components/CreateAKSProject/components/SearchableSelect.tsx @@ -107,7 +107,7 @@ export const SearchableSelect: React.FC = ({ /> )} loading={loading} - onChange={(e, newValue) => onChange(newValue?.value)} + onChange={(e, newValue) => onChange(newValue?.value ?? '')} renderOption={(props, option) => { const { key, ...optionProps } = props; return ( diff --git a/plugins/aks-desktop/src/components/CreateAKSProject/hooks/useBasicsStep.test.ts b/plugins/aks-desktop/src/components/CreateAKSProject/hooks/useBasicsStep.test.ts new file mode 100644 index 000000000..e31dcba38 --- /dev/null +++ b/plugins/aks-desktop/src/components/CreateAKSProject/hooks/useBasicsStep.test.ts @@ -0,0 +1,484 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the Apache 2.0. + +// @vitest-environment jsdom + +import { act, renderHook, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, test, vi } from 'vitest'; + +// --------------------------------------------------------------------------- +// Mocks (must be hoisted before any imports that pull in the real modules) +// --------------------------------------------------------------------------- + +const mockUseClustersConf = vi.hoisted(() => vi.fn()); +const mockUseAzureAuth = vi.hoisted(() => vi.fn()); + +vi.mock('@kinvolk/headlamp-plugin/lib', () => ({ + K8s: { + useClustersConf: mockUseClustersConf, + }, + useTranslation: () => ({ t: (key: string) => key }), +})); + +vi.mock('../../../hooks/useAzureAuth', () => ({ + useAzureAuth: mockUseAzureAuth, +})); + +// Import after mocks are in place +import type { BasicsStepProps } from '../types'; +import { + getClusterHelperText, + getClusterStateMessage, + isClusterNonReady, + useBasicsStep, +} from './useBasicsStep'; + +// --------------------------------------------------------------------------- +// Fixtures +// --------------------------------------------------------------------------- + +const SUBSCRIPTION = { + id: 'sub-123', + name: 'Production', + tenant: 'tenant-abc', + tenantName: 'Contoso', + status: 'Enabled', +}; + +const CLUSTER_RUNNING = { + name: 'aks-prod', + location: 'eastus', + version: '1.28.5', + nodeCount: 3, + status: 'Succeeded', + resourceGroup: 'rg-prod', + powerState: 'Running', +}; + +const CLUSTER_UPDATING = { + ...CLUSTER_RUNNING, + status: 'Updating', +}; + +function makeProps(overrides: Partial = {}): BasicsStepProps { + return { + formData: { + projectName: 'my-project', + description: '', + subscription: '', + cluster: '', + resourceGroup: '', + ingress: 'AllowSameNamespace', + egress: 'AllowAll', + cpuRequest: 2000, + memoryRequest: 4096, + cpuLimit: 2000, + memoryLimit: 4096, + userAssignments: [], + }, + onFormDataChange: vi.fn(), + validation: { isValid: true, errors: [], warnings: [] }, + loading: false, + error: null, + subscriptions: [SUBSCRIPTION], + clusters: [CLUSTER_RUNNING], + loadingClusters: false, + clusterError: null, + totalClusterCount: null, + extensionStatus: { installed: true, installing: false, error: null, showSuccess: false }, + featureStatus: { + registered: true, + state: 'Registered', + registering: false, + error: null, + showSuccess: false, + }, + namespaceStatus: { exists: null, checking: false, error: null }, + clusterCapabilities: null, + capabilitiesLoading: false, + onInstallExtension: vi.fn(), + onRegisterFeature: vi.fn(), + onRetrySubscriptions: vi.fn(), + onRetryClusters: vi.fn(), + onRefreshCapabilities: vi.fn(), + ...overrides, + }; +} + +const t = (key: string) => key; + +// --------------------------------------------------------------------------- +// Helper function tests (pure, no hooks) +// --------------------------------------------------------------------------- + +describe('getClusterHelperText', () => { + test('returns Entra ID note while loading', () => { + const result = getClusterHelperText(t, true, 0, null); + expect(result).toBe('Only clusters with Azure Entra ID authentication are shown.'); + }); + + test('reports zero clusters found when list is empty and nothing hidden', () => { + const result = getClusterHelperText(t, false, 0, 0); + expect(result).toContain('No eligible clusters found'); + expect(result).not.toContain('hidden'); + }); + + test('appends hidden count suffix when totalClusterCount > clusterCount', () => { + const result = getClusterHelperText(t, false, 2, 5); + // The stub t() does not interpolate — assert on the key text and the suffix + expect(result).toContain('eligible cluster(s) found'); + expect(result).toContain('hidden'); + }); + + test('reports eligible count when clusters are found', () => { + const result = getClusterHelperText(t, false, 3, 3); + expect(result).toContain('eligible cluster(s) found'); + expect(result).not.toContain('hidden'); + }); + + test('does not append hidden suffix when totalClusterCount is null', () => { + const result = getClusterHelperText(t, false, 2, null); + expect(result).not.toContain('hidden'); + }); +}); + +describe('isClusterNonReady', () => { + test.each([ + ['Updating', 'Running'], + ['Upgrading', 'Running'], + ['Deleting', 'Running'], + ['Creating', 'Running'], + ['Failed', 'Running'], + ])('returns true for provisioning state "%s"', (status, powerState) => { + expect(isClusterNonReady({ ...CLUSTER_RUNNING, status, powerState })).toBe(true); + }); + + test.each([ + ['Succeeded', 'Stopping'], + ['Succeeded', 'Stopped'], + ['Succeeded', 'Deallocating'], + ['Succeeded', 'Deallocated'], + ])('returns true for power state "%s"', (status, powerState) => { + expect(isClusterNonReady({ ...CLUSTER_RUNNING, status, powerState })).toBe(true); + }); + + test('returns false for a healthy running cluster', () => { + expect(isClusterNonReady(CLUSTER_RUNNING)).toBe(false); + }); + + test('is case-insensitive for provisioning state', () => { + expect(isClusterNonReady({ ...CLUSTER_RUNNING, status: 'FAILED' })).toBe(true); + }); + + test('is case-insensitive for power state', () => { + expect(isClusterNonReady({ ...CLUSTER_RUNNING, powerState: 'STOPPED' })).toBe(true); + }); +}); + +describe('getClusterStateMessage', () => { + test('returns updating message for Updating provisioning state', () => { + const msg = getClusterStateMessage({ ...CLUSTER_RUNNING, status: 'Updating' }, t); + expect(msg).toBe('Cluster is currently updating. Deployment may fail.'); + }); + + test('returns same updating message for Upgrading state', () => { + const msg = getClusterStateMessage({ ...CLUSTER_RUNNING, status: 'Upgrading' }, t); + expect(msg).toBe('Cluster is currently updating. Deployment may fail.'); + }); + + test('returns deleting message', () => { + const msg = getClusterStateMessage({ ...CLUSTER_RUNNING, status: 'Deleting' }, t); + expect(msg).toBe('Cluster is being deleted. Cannot deploy to this cluster.'); + }); + + test('returns creating message', () => { + const msg = getClusterStateMessage({ ...CLUSTER_RUNNING, status: 'Creating' }, t); + expect(msg).toBe('Cluster is still being created. Please wait until creation completes.'); + }); + + test('returns failed message', () => { + const msg = getClusterStateMessage({ ...CLUSTER_RUNNING, status: 'Failed' }, t); + expect(msg).toBe('Cluster is in a failed state. Please check Azure portal.'); + }); + + test('returns stopped message for Stopped power state', () => { + const msg = getClusterStateMessage({ ...CLUSTER_RUNNING, powerState: 'Stopped' }, t); + expect(msg).toBe('Cluster is stopped. Please start the cluster before deploying.'); + }); + + test('returns stopped message for Stopping power state', () => { + const msg = getClusterStateMessage({ ...CLUSTER_RUNNING, powerState: 'Stopping' }, t); + expect(msg).toBe('Cluster is stopped. Please start the cluster before deploying.'); + }); + + test('returns deallocated message', () => { + const msg = getClusterStateMessage({ ...CLUSTER_RUNNING, powerState: 'Deallocated' }, t); + expect(msg).toBe('Cluster is deallocated. Please start the cluster before deploying.'); + }); + + test('returns empty string for a healthy cluster', () => { + const msg = getClusterStateMessage(CLUSTER_RUNNING, t); + expect(msg).toBe(''); + }); +}); + +// --------------------------------------------------------------------------- +// useBasicsStep hook tests +// --------------------------------------------------------------------------- + +describe('useBasicsStep', () => { + beforeEach(() => { + vi.clearAllMocks(); + // Default: no clusters in headlamp kubeconfig + mockUseClustersConf.mockReturnValue({}); + // Default: not logged in, no subscriptionId + mockUseAzureAuth.mockReturnValue({ isLoggedIn: false, isChecking: false }); + }); + + test('maps subscriptions to SearchableSelectOption format', () => { + const { result } = renderHook(() => useBasicsStep(makeProps())); + expect(result.current.subscriptionOptions).toHaveLength(1); + expect(result.current.subscriptionOptions[0]).toMatchObject({ + value: 'sub-123', + label: 'Production', + }); + expect(result.current.subscriptionOptions[0].subtitle).toContain('Contoso'); + expect(result.current.subscriptionOptions[0].subtitle).toContain('Enabled'); + }); + + test('maps clusters to SearchableSelectOption format', () => { + const { result } = renderHook(() => useBasicsStep(makeProps())); + expect(result.current.clusterOptions).toHaveLength(1); + expect(result.current.clusterOptions[0]).toMatchObject({ + value: 'aks-prod', + label: 'aks-prod', + }); + expect(result.current.clusterOptions[0].subtitle).toContain('eastus'); + expect(result.current.clusterOptions[0].subtitle).toContain('1.28.5'); + expect(result.current.clusterOptions[0].subtitle).toContain('nodes'); + }); + + test('selectedSubscription is undefined when no subscription is selected', () => { + const { result } = renderHook(() => useBasicsStep(makeProps())); + expect(result.current.selectedSubscription).toBeUndefined(); + }); + + test('selectedSubscription returns the matching subscription object', () => { + const props = makeProps({ + formData: { ...makeProps().formData, subscription: 'sub-123' }, + }); + const { result } = renderHook(() => useBasicsStep(props)); + expect(result.current.selectedSubscription).toEqual(SUBSCRIPTION); + }); + + test('selectedCluster, isClusterMissing, and nonReadyCluster are all falsy when no cluster is selected', () => { + const { result } = renderHook(() => useBasicsStep(makeProps())); + expect(result.current.selectedCluster).toBeUndefined(); + expect(result.current.isClusterMissing).toBe(false); + expect(result.current.nonReadyCluster).toBeNull(); + }); + + test('selectedCluster returns the matching cluster object', () => { + const props = makeProps({ + formData: { + projectName: 'my-project', + description: '', + subscription: 'sub-123', + cluster: 'aks-prod', + resourceGroup: 'rg-prod', + ingress: 'AllowSameNamespace', + egress: 'AllowAll', + cpuRequest: 2000, + memoryRequest: 4096, + cpuLimit: 2000, + memoryLimit: 4096, + userAssignments: [], + }, + }); + const { result } = renderHook(() => useBasicsStep(props)); + expect(result.current.selectedCluster).toEqual(CLUSTER_RUNNING); + }); + + test('isClusterMissing is true when cluster is selected but absent from headlamp', () => { + mockUseClustersConf.mockReturnValue({}); + const props = makeProps({ + formData: { + projectName: 'my-project', + description: '', + subscription: 'sub-123', + cluster: 'aks-prod', + resourceGroup: 'rg-prod', + ingress: 'AllowSameNamespace', + egress: 'AllowAll', + cpuRequest: 2000, + memoryRequest: 4096, + cpuLimit: 2000, + memoryLimit: 4096, + userAssignments: [], + }, + }); + const { result } = renderHook(() => useBasicsStep(props)); + expect(result.current.isClusterMissing).toBe(true); + }); + + test('isClusterMissing is false when the cluster is present in headlamp', () => { + mockUseClustersConf.mockReturnValue({ 'ctx-1': { name: 'aks-prod' } }); + const props = makeProps({ + formData: { + projectName: 'my-project', + description: '', + subscription: 'sub-123', + cluster: 'aks-prod', + resourceGroup: 'rg-prod', + ingress: 'AllowSameNamespace', + egress: 'AllowAll', + cpuRequest: 2000, + memoryRequest: 4096, + cpuLimit: 2000, + memoryLimit: 4096, + userAssignments: [], + }, + }); + const { result } = renderHook(() => useBasicsStep(props)); + expect(result.current.isClusterMissing).toBe(false); + }); + + test('nonReadyCluster is null for a healthy running cluster', () => { + const props = makeProps({ + formData: { + projectName: 'my-project', + description: '', + subscription: 'sub-123', + cluster: 'aks-prod', + resourceGroup: 'rg-prod', + ingress: 'AllowSameNamespace', + egress: 'AllowAll', + cpuRequest: 2000, + memoryRequest: 4096, + cpuLimit: 2000, + memoryLimit: 4096, + userAssignments: [], + }, + }); + const { result } = renderHook(() => useBasicsStep(props)); + expect(result.current.nonReadyCluster).toBeNull(); + }); + + test('nonReadyCluster is populated for an updating cluster', () => { + const props = makeProps({ + clusters: [CLUSTER_UPDATING], + formData: { + projectName: 'my-project', + description: '', + subscription: 'sub-123', + cluster: 'aks-prod', + resourceGroup: 'rg-prod', + ingress: 'AllowSameNamespace', + egress: 'AllowAll', + cpuRequest: 2000, + memoryRequest: 4096, + cpuLimit: 2000, + memoryLimit: 4096, + userAssignments: [], + }, + }); + const { result } = renderHook(() => useBasicsStep(props)); + expect(result.current.nonReadyCluster).not.toBeNull(); + expect(result.current.nonReadyCluster?.cluster).toEqual(CLUSTER_UPDATING); + expect(result.current.nonReadyCluster?.message).toBe( + 'Cluster is currently updating. Deployment may fail.' + ); + }); + + test('handleInputChange calls onFormDataChange with the correct field patch', () => { + const onFormDataChange = vi.fn(); + const { result } = renderHook(() => useBasicsStep(makeProps({ onFormDataChange }))); + act(() => result.current.handleInputChange('projectName', 'new-name')); + expect(onFormDataChange).toHaveBeenCalledWith({ projectName: 'new-name' }); + }); + + test('handleClusterChange updates both cluster and resourceGroup', () => { + const onFormDataChange = vi.fn(); + const { result } = renderHook(() => useBasicsStep(makeProps({ onFormDataChange }))); + act(() => result.current.handleClusterChange('aks-prod')); + expect(onFormDataChange).toHaveBeenCalledWith({ + cluster: 'aks-prod', + resourceGroup: 'rg-prod', + }); + }); + + test('handleClusterChange does nothing when the cluster name is not in the list', () => { + const onFormDataChange = vi.fn(); + const { result } = renderHook(() => useBasicsStep(makeProps({ onFormDataChange }))); + act(() => result.current.handleClusterChange('nonexistent-cluster')); + expect(onFormDataChange).not.toHaveBeenCalled(); + }); + + test('handleClusterChange resets cluster and resourceGroup when called with empty string', () => { + const onFormDataChange = vi.fn(); + const { result } = renderHook(() => useBasicsStep(makeProps({ onFormDataChange }))); + act(() => result.current.handleClusterChange('')); + expect(onFormDataChange).toHaveBeenCalledWith({ cluster: '', resourceGroup: '' }); + }); + + test('auto-selects default subscription when authStatus matches and none is selected', async () => { + mockUseAzureAuth.mockReturnValue({ + isLoggedIn: true, + isChecking: false, + subscriptionId: 'sub-123', + }); + const onFormDataChange = vi.fn(); + renderHook(() => useBasicsStep(makeProps({ onFormDataChange }))); + await waitFor(() => { + expect(onFormDataChange).toHaveBeenCalledWith({ subscription: 'sub-123' }); + }); + }); + + test('does not auto-select subscription when one is already chosen', async () => { + mockUseAzureAuth.mockReturnValue({ + isLoggedIn: true, + isChecking: false, + subscriptionId: 'sub-123', + }); + const onFormDataChange = vi.fn(); + renderHook(() => + useBasicsStep( + makeProps({ + onFormDataChange, + formData: { + projectName: 'my-project', + description: '', + subscription: 'sub-123', + cluster: '', + resourceGroup: '', + ingress: 'AllowSameNamespace', + egress: 'AllowAll', + cpuRequest: 2000, + memoryRequest: 4096, + cpuLimit: 2000, + memoryLimit: 4096, + userAssignments: [], + }, + }) + ) + ); + // Allow any pending effects to flush before asserting the negative + await waitFor(() => { + expect(onFormDataChange).not.toHaveBeenCalled(); + }); + }); + + test('does not auto-select when authStatus subscriptionId is not in the list', async () => { + mockUseAzureAuth.mockReturnValue({ + isLoggedIn: true, + isChecking: false, + subscriptionId: 'sub-999', + }); + const onFormDataChange = vi.fn(); + renderHook(() => useBasicsStep(makeProps({ onFormDataChange }))); + // Allow any pending effects to flush before asserting the negative + await waitFor(() => { + expect(onFormDataChange).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/plugins/aks-desktop/src/components/CreateAKSProject/hooks/useBasicsStep.ts b/plugins/aks-desktop/src/components/CreateAKSProject/hooks/useBasicsStep.ts new file mode 100644 index 000000000..770cf4d1d --- /dev/null +++ b/plugins/aks-desktop/src/components/CreateAKSProject/hooks/useBasicsStep.ts @@ -0,0 +1,296 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the Apache 2.0. + +import { K8s, useTranslation } from '@kinvolk/headlamp-plugin/lib'; +import { useEffect, useRef } from 'react'; +import { useAzureAuth } from '../../../hooks/useAzureAuth'; +import type { SearchableSelectOption } from '../components/SearchableSelect'; +import type { AzureCluster, AzureSubscription, FormData } from '../types'; + +/** + * The subset of {@link BasicsStepProps} that {@link useBasicsStep} actually + * reads. Keeping the hook's input narrow makes the dependency contract explicit + * and simplifies testing. + */ +export interface UseBasicsStepInput { + formData: FormData; + onFormDataChange: (updates: Partial) => void; + subscriptions: AzureSubscription[]; + clusters: AzureCluster[]; + loadingClusters: boolean; + totalClusterCount: number | null; +} + +// --------------------------------------------------------------------------- +// Pure helper functions (no hooks, fully testable in isolation) +// --------------------------------------------------------------------------- + +/** + * Returns the helper text shown below the Cluster select field. + * + * - While clusters are loading, shows a static note about the Entra ID filter. + * - After loading, reports how many eligible clusters were found and how many + * were hidden because they lack Azure Entra ID authentication. + * + * @param t - The i18n translation function. + * @param loadingClusters - Whether clusters are currently being fetched. + * @param clusterCount - Number of eligible (Entra ID) clusters in the list. + * @param totalClusterCount - Total clusters in the subscription before filtering, + * or `null` if not yet known. + */ +export function getClusterHelperText( + t: (key: string, options?: Record) => string, + loadingClusters: boolean, + clusterCount: number, + totalClusterCount: number | null +): string { + if (loadingClusters) { + return t('Only clusters with Azure Entra ID authentication are shown.'); + } + const hiddenCount = + totalClusterCount !== null && totalClusterCount > clusterCount + ? totalClusterCount - clusterCount + : 0; + const hiddenSuffix = + hiddenCount > 0 + ? ` (${t('{{count}} cluster(s) hidden — no Azure Entra ID', { count: hiddenCount })})` + : ''; + if (clusterCount === 0) { + return `${t('No eligible clusters found in this subscription.')}${hiddenSuffix}`; + } + return `${t('{{count}} eligible cluster(s) found.', { count: clusterCount })}${hiddenSuffix}`; +} + +/** + * Returns `true` when the cluster is in a provisioning or power state that + * makes deployment unreliable (updating, upgrading, deleting, creating, + * failed, stopping, stopped, deallocating, or deallocated). + * + * @param cluster - The Azure cluster to inspect. + */ +export function isClusterNonReady(cluster: AzureCluster): boolean { + const provisioningState = cluster.status?.toLowerCase() || ''; + const powerState = cluster.powerState?.toLowerCase() || ''; + + const nonReadyProvisioningStates = ['updating', 'upgrading', 'deleting', 'creating', 'failed']; + const nonReadyPowerStates = ['stopping', 'stopped', 'deallocating', 'deallocated']; + + return ( + nonReadyProvisioningStates.includes(provisioningState) || + nonReadyPowerStates.includes(powerState) + ); +} + +/** + * Returns a human-readable warning message for the cluster's current + * non-ready state, or an empty string if the cluster is ready. + * + * @param cluster - The Azure cluster to inspect. + * @param t - The i18n translation function. + */ +export function getClusterStateMessage(cluster: AzureCluster, t: (key: string) => string): string { + const provisioningState = cluster.status?.toLowerCase() || ''; + const powerState = cluster.powerState?.toLowerCase() || ''; + + if (provisioningState === 'updating' || provisioningState === 'upgrading') { + return t('Cluster is currently updating. Deployment may fail.'); + } + if (provisioningState === 'deleting') { + return t('Cluster is being deleted. Cannot deploy to this cluster.'); + } + if (provisioningState === 'creating') { + return t('Cluster is still being created. Please wait until creation completes.'); + } + if (provisioningState === 'failed') { + return t('Cluster is in a failed state. Please check Azure portal.'); + } + if (powerState === 'stopped' || powerState === 'stopping') { + return t('Cluster is stopped. Please start the cluster before deploying.'); + } + if (powerState === 'deallocated' || powerState === 'deallocating') { + return t('Cluster is deallocated. Please start the cluster before deploying.'); + } + return ''; +} + +// --------------------------------------------------------------------------- +// Hook return type +// --------------------------------------------------------------------------- + +/** + * Return type for {@link useBasicsStep}. + */ +export interface UseBasicsStepResult { + /** Ref attached to the Project Name input to steal focus on mount. */ + projectNameRef: React.RefObject; + /** Subscription list formatted for {@link SearchableSelect}. */ + subscriptionOptions: SearchableSelectOption[]; + /** Cluster list formatted for {@link SearchableSelect}. */ + clusterOptions: SearchableSelectOption[]; + /** Helper text shown below the Cluster select field. */ + clusterHelperText: string; + /** The currently selected Azure subscription object, or `undefined` if none. */ + selectedSubscription: AzureSubscription | undefined; + /** The currently selected Azure cluster object, or `undefined` if none. */ + selectedCluster: AzureCluster | undefined; + /** + * `true` when a cluster is selected but is not present in the headlamp + * kubeconfig — the user must register it before proceeding. + */ + isClusterMissing: boolean; + /** + * When the selected cluster is in a non-ready state, contains the cluster + * object and a pre-translated warning message. `null` otherwise. + */ + nonReadyCluster: { cluster: AzureCluster; message: string } | null; + /** + * Generic field change handler. Calls `onFormDataChange` with a single + * key-value patch. + */ + handleInputChange: (field: K, value: FormData[K]) => void; + /** + * Cluster selection handler. Updates both `cluster` and `resourceGroup` + * together so they stay in sync. + */ + handleClusterChange: (clusterName: string) => void; +} + +// --------------------------------------------------------------------------- +// Hook +// --------------------------------------------------------------------------- + +/** + * Encapsulates all stateful logic for the Basics step of the Create AKS + * Project wizard. + * + * Responsibilities: + * - Focus the Project Name input on mount (when nothing else has focus). + * - Auto-select the default subscription once `authStatus.subscriptionId` is + * known and matches an entry in the subscription list. + * - Derive display-ready option lists for the subscription and cluster + * `SearchableSelect` fields. + * - Compute the cluster helper text, selected subscription and cluster objects, + * missing-cluster flag, and non-ready cluster warning from the current form state. + * - Provide `handleInputChange` and `handleClusterChange` callbacks that + * delegate to `props.onFormDataChange`. + * + * @param props - The fields from {@link UseBasicsStepInput} that the hook needs. + */ +export function useBasicsStep(props: UseBasicsStepInput): UseBasicsStepResult { + const { t } = useTranslation(); + const { + formData, + onFormDataChange, + subscriptions, + clusters, + loadingClusters, + totalClusterCount, + } = props; + + const headlampClusters = K8s.useClustersConf(); + const authStatus = useAzureAuth(); + + // Focus the Project Name input on mount. Only steals focus when nothing + // else is focused (activeElement is ) so it doesn't interrupt + // interactions that started before the AzureAuthGuard finished mounting. + const projectNameRef = useRef(null); + useEffect(() => { + if (document.activeElement?.tagName === 'BODY') { + projectNameRef.current?.focus(); + } + }, []); + + // Auto-select the default subscription exactly once. The ref guards against + // re-running when the effect re-fires due to unrelated dependency changes. + const autoSelected = useRef(false); + useEffect(() => { + if ( + !autoSelected.current && + authStatus?.subscriptionId && + !formData.subscription && + subscriptions && + subscriptions.find(it => it.id === authStatus.subscriptionId) + ) { + autoSelected.current = true; + onFormDataChange({ subscription: authStatus.subscriptionId }); + } + }, [formData.subscription, authStatus?.subscriptionId, subscriptions, onFormDataChange]); + + // --------------------------------------------------------------------------- + // Derived values + // --------------------------------------------------------------------------- + + const subscriptionOptions: SearchableSelectOption[] = subscriptions.map(sub => ({ + value: sub.id, + label: sub.name, + subtitle: `${t('Tenant')}: ${sub.tenantName} - (${sub.tenant}) • ${t('Status')}: ${sub.status}`, + })); + + const clusterOptions: SearchableSelectOption[] = clusters.map(cluster => ({ + value: cluster.name, + label: cluster.name, + subtitle: `${cluster.location} • ${cluster.version} • ${t('{{count}} nodes', { + count: cluster.nodeCount, + })} • ${cluster.status}`, + })); + + const clusterHelperText = getClusterHelperText( + t, + loadingClusters, + clusters.length, + totalClusterCount + ); + + const selectedSubscription = formData.subscription + ? subscriptions.find(s => s.id === formData.subscription) + : undefined; + + const selectedCluster = formData.cluster + ? clusters.find(c => c.name === formData.cluster) + : undefined; + + const isClusterMissing = + selectedCluster !== undefined && + Object.values(headlampClusters || {}).find((it: any) => it.name === selectedCluster.name) === + undefined; + + const nonReadyCluster: UseBasicsStepResult['nonReadyCluster'] = + selectedCluster && isClusterNonReady(selectedCluster) + ? { + cluster: selectedCluster, + message: getClusterStateMessage(selectedCluster, t), + } + : null; + + // --------------------------------------------------------------------------- + // Callbacks + // --------------------------------------------------------------------------- + + const handleInputChange = (field: K, value: FormData[K]) => { + onFormDataChange({ [field]: value } as Pick); + }; + + const handleClusterChange = (clusterName: string) => { + if (!clusterName) { + onFormDataChange({ cluster: '', resourceGroup: '' }); + return; + } + const found = clusters.find(c => c.name === clusterName); + if (found) { + onFormDataChange({ cluster: clusterName, resourceGroup: found.resourceGroup }); + } + }; + + return { + projectNameRef, + subscriptionOptions, + clusterOptions, + clusterHelperText, + selectedSubscription, + selectedCluster, + isClusterMissing, + nonReadyCluster, + handleInputChange, + handleClusterChange, + }; +} diff --git a/plugins/aks-desktop/src/components/CreateAKSProject/hooks/useRegisterCluster.test.ts b/plugins/aks-desktop/src/components/CreateAKSProject/hooks/useRegisterCluster.test.ts new file mode 100644 index 000000000..64ec90699 --- /dev/null +++ b/plugins/aks-desktop/src/components/CreateAKSProject/hooks/useRegisterCluster.test.ts @@ -0,0 +1,215 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the Apache 2.0. + +// @vitest-environment jsdom + +import { act, renderHook } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; + +// --------------------------------------------------------------------------- +// Mocks +// --------------------------------------------------------------------------- + +const mockRegisterAKSCluster = vi.hoisted(() => vi.fn()); + +vi.mock('../../../utils/azure/aks', () => ({ + registerAKSCluster: mockRegisterAKSCluster, +})); + +vi.mock('@kinvolk/headlamp-plugin/lib', () => ({ + useTranslation: () => ({ + t: (key: string, opts?: any) => { + // Minimal interpolation so success/error messages come through correctly + if (!opts) return key; + return key.replace(/\{\{(\w+)\}\}/g, (_: string, k: string) => opts[k] ?? k); + }, + }), +})); + +import { useRegisterCluster } from './useRegisterCluster'; + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('useRegisterCluster', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + test('starts with loading=false, no error, no success', () => { + const { result } = renderHook(() => useRegisterCluster('aks-prod', 'rg-prod', 'sub-123')); + expect(result.current.loading).toBe(false); + expect(result.current.error).toBeUndefined(); + expect(result.current.success).toBeUndefined(); + }); + + test('handleRegister sets loading=true while the call is in flight', async () => { + let resolveRegister!: (value: { success: boolean; message: string }) => void; + const registerPromise = new Promise<{ success: boolean; message: string }>(resolve => { + resolveRegister = resolve; + }); + mockRegisterAKSCluster.mockReturnValue(registerPromise); + const { result } = renderHook(() => useRegisterCluster('aks-prod', 'rg-prod', 'sub-123')); + + let handleRegisterPromise!: Promise; + act(() => { + handleRegisterPromise = result.current.handleRegister(); + }); + expect(result.current.loading).toBe(true); + + await act(async () => { + resolveRegister({ success: true, message: 'ok' }); + await handleRegisterPromise; + }); + }); + + test('handleRegister sets success message on result.success=true', async () => { + mockRegisterAKSCluster.mockResolvedValue({ success: true, message: 'ok' }); + const { result } = renderHook(() => useRegisterCluster('aks-prod', 'rg-prod', 'sub-123')); + + await act(async () => { + await result.current.handleRegister(); + }); + + expect(result.current.loading).toBe(false); + expect(result.current.error).toBeUndefined(); + expect(result.current.success).toContain('aks-prod'); + }); + + test('handleRegister sets error when result.success=false', async () => { + mockRegisterAKSCluster.mockResolvedValue({ success: false, message: 'credentials expired' }); + const { result } = renderHook(() => useRegisterCluster('aks-prod', 'rg-prod', 'sub-123')); + + await act(async () => { + await result.current.handleRegister(); + }); + + expect(result.current.loading).toBe(false); + expect(result.current.error).toBe('credentials expired'); + expect(result.current.success).toBeUndefined(); + }); + + test('handleRegister sets error on thrown Error', async () => { + mockRegisterAKSCluster.mockRejectedValue(new Error('network timeout')); + const { result } = renderHook(() => useRegisterCluster('aks-prod', 'rg-prod', 'sub-123')); + + await act(async () => { + await result.current.handleRegister(); + }); + + expect(result.current.loading).toBe(false); + expect(result.current.error).toContain('network timeout'); + expect(result.current.success).toBeUndefined(); + }); + + test('handleRegister uses "Unknown error" for non-Error rejections', async () => { + mockRegisterAKSCluster.mockRejectedValue('something went wrong'); + const { result } = renderHook(() => useRegisterCluster('aks-prod', 'rg-prod', 'sub-123')); + + await act(async () => { + await result.current.handleRegister(); + }); + + expect(result.current.error).toContain('Unknown error'); + }); + + test('handleRegister passes subscription, resourceGroup, cluster, and tenantId to registerAKSCluster', async () => { + mockRegisterAKSCluster.mockResolvedValue({ success: true, message: '' }); + const { result } = renderHook(() => + useRegisterCluster('aks-prod', 'rg-prod', 'sub-123', 'tenant-abc') + ); + + await act(async () => { + await result.current.handleRegister(); + }); + + expect(mockRegisterAKSCluster).toHaveBeenCalledWith( + 'sub-123', + 'rg-prod', + 'aks-prod', + undefined, + 'tenant-abc' + ); + }); + + test('handleRegister does not call registerAKSCluster when cluster is empty', async () => { + const { result } = renderHook(() => useRegisterCluster('', 'rg-prod', 'sub-123')); + + await act(async () => { + await result.current.handleRegister(); + }); + + expect(mockRegisterAKSCluster).not.toHaveBeenCalled(); + }); + + test('handleRegister does not call registerAKSCluster when subscription is empty', async () => { + const { result } = renderHook(() => useRegisterCluster('aks-prod', 'rg-prod', '')); + + await act(async () => { + await result.current.handleRegister(); + }); + + expect(mockRegisterAKSCluster).not.toHaveBeenCalled(); + }); + + test('clearError resets error to undefined', async () => { + mockRegisterAKSCluster.mockResolvedValue({ success: false, message: 'something failed' }); + const { result } = renderHook(() => useRegisterCluster('aks-prod', 'rg-prod', 'sub-123')); + + await act(async () => { + await result.current.handleRegister(); + }); + expect(result.current.error).toBeDefined(); + + act(() => result.current.clearError()); + expect(result.current.error).toBeUndefined(); + }); + + test('clearSuccess resets success to undefined', async () => { + mockRegisterAKSCluster.mockResolvedValue({ success: true, message: '' }); + const { result } = renderHook(() => useRegisterCluster('aks-prod', 'rg-prod', 'sub-123')); + + await act(async () => { + await result.current.handleRegister(); + }); + expect(result.current.success).toBeDefined(); + + act(() => result.current.clearSuccess()); + expect(result.current.success).toBeUndefined(); + }); + + test('clears previous error before a new registration attempt', async () => { + let resolveSecond!: (value: { success: boolean; message: string }) => void; + const secondPromise = new Promise<{ success: boolean; message: string }>(resolve => { + resolveSecond = resolve; + }); + mockRegisterAKSCluster + .mockResolvedValueOnce({ success: false, message: 'first error' }) + .mockReturnValueOnce(secondPromise); + + const { result } = renderHook(() => useRegisterCluster('aks-prod', 'rg-prod', 'sub-123')); + + await act(async () => { + await result.current.handleRegister(); + }); + expect(result.current.error).toBe('first error'); + + let secondHandlePromise!: Promise; + act(() => { + secondHandlePromise = result.current.handleRegister(); + }); + // error should be cleared immediately when the second attempt starts + expect(result.current.error).toBeUndefined(); + + await act(async () => { + resolveSecond({ success: true, message: 'ok' }); + await secondHandlePromise; + }); + }); +}); diff --git a/plugins/aks-desktop/src/components/CreateAKSProject/hooks/useRegisterCluster.ts b/plugins/aks-desktop/src/components/CreateAKSProject/hooks/useRegisterCluster.ts new file mode 100644 index 000000000..aeb28d386 --- /dev/null +++ b/plugins/aks-desktop/src/components/CreateAKSProject/hooks/useRegisterCluster.ts @@ -0,0 +1,108 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the Apache 2.0. + +import { useTranslation } from '@kinvolk/headlamp-plugin/lib'; +import { useState } from 'react'; +import { registerAKSCluster } from '../../../utils/azure/aks'; + +/** Set to `true` locally to enable verbose debug logging. Never enable in production. */ +const DEBUG = false; + +// --------------------------------------------------------------------------- +// Return type +// --------------------------------------------------------------------------- + +/** + * Return type for {@link useRegisterCluster}. + */ +export interface UseRegisterClusterResult { + /** `true` while the `az aks get-credentials` call is in flight. */ + loading: boolean; + /** Error message from the last failed registration attempt, or `undefined`. */ + error: string | undefined; + /** Success message once registration completes, or `undefined`. */ + success: string | undefined; + /** Initiates the cluster registration flow. */ + handleRegister: () => Promise; + /** Clears the error message (e.g. when the user dismisses the alert). */ + clearError: () => void; + /** Clears the success message (e.g. when the user dismisses the alert). */ + clearSuccess: () => void; +} + +// --------------------------------------------------------------------------- +// Hook +// --------------------------------------------------------------------------- + +/** + * Manages the async flow for registering a missing AKS cluster into the + * headlamp kubeconfig via `az aks get-credentials`. + * + * Encapsulates the loading / error / success state that previously lived + * inline in the `RegisterCluster` component so the component can be a pure + * presentational function. + * + * @param cluster - The AKS cluster name to register. + * @param resourceGroup - The resource group the cluster belongs to. + * @param subscription - The Azure subscription ID. + * @param tenantId - Optional tenant ID for multi-tenant environments. + */ +export function useRegisterCluster( + cluster: string, + resourceGroup: string, + subscription: string, + tenantId?: string +): UseRegisterClusterResult { + const { t } = useTranslation(); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(undefined); + const [success, setSuccess] = useState(undefined); + + const handleRegister = async () => { + if (!cluster || !resourceGroup || !subscription) { + return; + } + + setLoading(true); + setError(undefined); + setSuccess(undefined); + + try { + if (DEBUG) console.debug('[AKS] Registering cluster...'); + const result = await registerAKSCluster( + subscription, + resourceGroup, + cluster, + undefined, + tenantId + ); + if (DEBUG) console.debug('[AKS] Register cluster result:', result.success); + + if (!result.success) { + setError(result.message); + return; + } + + if (DEBUG) console.debug('[AKS] Cluster registered successfully.', result.message); + setSuccess(t("Cluster '{{cluster}}' successfully merged in kubeconfig", { cluster })); + } catch (err) { + console.error('Error registering AKS cluster:', err); + setError( + t('Failed to register cluster: {{message}}', { + message: err instanceof Error ? err.message : t('Unknown error'), + }) + ); + } finally { + setLoading(false); + } + }; + + return { + loading, + error, + success, + handleRegister, + clearError: () => setError(undefined), + clearSuccess: () => setSuccess(undefined), + }; +} diff --git a/plugins/aks-desktop/src/components/CreateNamespace/CreateNamespace.tsx b/plugins/aks-desktop/src/components/CreateNamespace/CreateNamespace.tsx index 28197c15a..fa8e4d960 100644 --- a/plugins/aks-desktop/src/components/CreateNamespace/CreateNamespace.tsx +++ b/plugins/aks-desktop/src/components/CreateNamespace/CreateNamespace.tsx @@ -164,7 +164,7 @@ function CreateNamespaceContent() { setNamespaceName(String(value).toLowerCase())} + onChange={value => setNamespaceName(value.toLowerCase())} error={namespaceName.length > 0 && !NAMESPACE_NAME_REGEX.test(namespaceName)} helperText={ namespaceName.length > 0 && !NAMESPACE_NAME_REGEX.test(namespaceName) diff --git a/plugins/aks-desktop/src/components/shared/ComputeStep.tsx b/plugins/aks-desktop/src/components/shared/ComputeStep.tsx index 037ad48e7..f10e526e8 100644 --- a/plugins/aks-desktop/src/components/shared/ComputeStep.tsx +++ b/plugins/aks-desktop/src/components/shared/ComputeStep.tsx @@ -47,7 +47,7 @@ export const ComputeStep: React.FC = ({ label={t('CPU Requests')} type="number" value={formData.cpuRequest} - onChange={value => handleInputChange('cpuRequest', value as number)} + onChange={value => handleInputChange('cpuRequest', Number(value))} disabled={loading} helperText={ getFieldError('cpuRequest') || t('Minimum CPU guaranteed (1000m = 1 CPU core)') @@ -80,7 +80,7 @@ export const ComputeStep: React.FC = ({ label={t('CPU Limits')} type="number" value={formData.cpuLimit} - onChange={value => handleInputChange('cpuLimit', value as number)} + onChange={value => handleInputChange('cpuLimit', Number(value))} disabled={loading} helperText={ getFieldError('cpuLimit') || t('Maximum CPU allowed (1000m = 1 CPU core)') @@ -116,7 +116,7 @@ export const ComputeStep: React.FC = ({ label={t('Memory Requests')} type="number" value={formData.memoryRequest} - onChange={value => handleInputChange('memoryRequest', value as number)} + onChange={value => handleInputChange('memoryRequest', Number(value))} disabled={loading} helperText={ getFieldError('memoryRequest') || t('Minimum memory guaranteed (1024 MiB = 1 GiB)') @@ -147,7 +147,7 @@ export const ComputeStep: React.FC = ({ label={t('Memory Limits')} type="number" value={formData.memoryLimit} - onChange={value => handleInputChange('memoryLimit', value as number)} + onChange={value => handleInputChange('memoryLimit', Number(value))} disabled={loading} helperText={ getFieldError('memoryLimit') || t('Maximum memory allowed (1024 MiB = 1 GiB)') diff --git a/plugins/aks-desktop/src/components/shared/FormField.tsx b/plugins/aks-desktop/src/components/shared/FormField.tsx index a418ddd98..d0fbd9814 100644 --- a/plugins/aks-desktop/src/components/shared/FormField.tsx +++ b/plugins/aks-desktop/src/components/shared/FormField.tsx @@ -8,7 +8,7 @@ import React from 'react'; export interface FormFieldProps { label: string; value: string | number; - onChange: (value: string | number) => void; + onChange: (value: string) => void; type?: 'text' | 'email' | 'number' | 'textarea'; multiline?: boolean; rows?: number; @@ -49,7 +49,7 @@ export const FormField: React.FC = ({ const numValue = parseFloat(event.target.value) || 0; // Prevent negative values for number inputs const validValue = Math.max(0, numValue); - onChange(validValue); + onChange(String(validValue)); } else { onChange(event.target.value); }