diff --git a/plugins/aks-desktop/src/components/InfoTab/hooks/useInfoTab.test.ts b/plugins/aks-desktop/src/components/InfoTab/hooks/useInfoTab.test.ts index c946f963c..67ea646e5 100644 --- a/plugins/aks-desktop/src/components/InfoTab/hooks/useInfoTab.test.ts +++ b/plugins/aks-desktop/src/components/InfoTab/hooks/useInfoTab.test.ts @@ -11,6 +11,13 @@ const mockGetManagedNamespaces = vi.hoisted(() => vi.fn()); const mockGetManagedNamespaceDetails = vi.hoisted(() => vi.fn()); const mockUpdateManagedNamespace = vi.hoisted(() => vi.fn()); const mockT = vi.hoisted(() => (key: string) => key); +const mockClusterAction = vi.hoisted(() => + vi.fn(async (action: () => Promise) => { + try { + await action(); + } catch {} + }) +); vi.mock('@kinvolk/headlamp-plugin/lib', () => ({ K8s: { @@ -21,6 +28,7 @@ vi.mock('@kinvolk/headlamp-plugin/lib', () => ({ }, }, useTranslation: () => ({ t: mockT }), + clusterAction: mockClusterAction, })); vi.mock('../../../utils/azure/az-namespaces', () => ({ @@ -331,7 +339,7 @@ describe('useInfoTab', () => { expect(result.current.hasChanges).toBe(false); }); - test('handleSave sets error and leaves updating false when updateManagedNamespace throws', async () => { + test('handleSave leaves updating as false when updateManagedNamespace throws', async () => { mockUpdateManagedNamespace.mockRejectedValue(new Error('update failed')); const { result } = renderHook(() => useInfoTab(defaultProject)); @@ -346,38 +354,7 @@ describe('useInfoTab', () => { await result.current.handleSave(); }); - expect(result.current.error).toBe('Failed to update managed namespace'); - expect(result.current.updating).toBe(false); - }); - - test('handleSave clears a previous error on success', async () => { - mockUpdateManagedNamespace.mockRejectedValueOnce(new Error('update failed')); - mockUpdateManagedNamespace.mockResolvedValueOnce(undefined); - - const { result } = renderHook(() => useInfoTab(defaultProject)); - - await waitFor(() => expect(result.current.loading).toBe(false)); - - act(() => { - result.current.handleFormDataChange({ ingress: 'DenyAll' }); - }); - - await act(async () => { - await result.current.handleSave(); - }); - - expect(result.current.error).toBe('Failed to update managed namespace'); - - act(() => { - result.current.handleFormDataChange({ ingress: 'AllowAll' }); - }); - - await act(async () => { - await result.current.handleSave(); - }); - - expect(result.current.error).toBeNull(); - expect(mockUpdateManagedNamespace).toHaveBeenCalledTimes(2); + await waitFor(() => expect(result.current.updating).toBe(false)); }); test('handleSave is a no-op when resourceGroup label is absent', async () => { diff --git a/plugins/aks-desktop/src/components/InfoTab/hooks/useInfoTab.ts b/plugins/aks-desktop/src/components/InfoTab/hooks/useInfoTab.ts index ff125e8ca..d0ab4dbe3 100644 --- a/plugins/aks-desktop/src/components/InfoTab/hooks/useInfoTab.ts +++ b/plugins/aks-desktop/src/components/InfoTab/hooks/useInfoTab.ts @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the Apache 2.0. -import { K8s, useTranslation } from '@kinvolk/headlamp-plugin/lib'; +import { clusterAction, K8s, useTranslation } from '@kinvolk/headlamp-plugin/lib'; import { useCallback, useEffect, useMemo, useState } from 'react'; import { getManagedNamespaceDetails, @@ -81,7 +81,7 @@ export interface UseInfoTabResult { /** Updates form fields and re-validates. */ handleFormDataChange: (updates: Partial) => void; /** Persists the current form data to the managed namespace. */ - handleSave: () => Promise; + handleSave: () => void; } /** @@ -253,32 +253,41 @@ export const useInfoTab = (project: { return keys.some(k => formData[k] !== baselineFormData[k]); }, [baselineFormData, formData]); - const handleSave = useCallback(async () => { + const handleSave = useCallback(() => { if (!resourceGroup || !clusterName || !projectId) return; - try { - setUpdating(true); - await updateManagedNamespace({ - clusterName, - resourceGroup, - namespaceName: projectId, - ingressPolicy: formData.ingress, - egressPolicy: formData.egress, - cpuRequest: formData.cpuRequest, - cpuLimit: formData.cpuLimit, - memoryRequest: formData.memoryRequest, - memoryLimit: formData.memoryLimit, - noWait: false, - }); - setBaselineFormData(formData); - setError(null); - } catch (e) { - console.error('Failed to update managed namespace', e); - setError(t('Failed to update managed namespace')); - } finally { - setUpdating(false); - } - }, [resourceGroup, clusterName, projectId, formData]); + setUpdating(true); + clusterAction( + async () => { + try { + await updateManagedNamespace({ + clusterName, + resourceGroup, + namespaceName: projectId, + ingressPolicy: formData.ingress, + egressPolicy: formData.egress, + cpuRequest: formData.cpuRequest, + cpuLimit: formData.cpuLimit, + memoryRequest: formData.memoryRequest, + memoryLimit: formData.memoryLimit, + subscriptionId: subscription, + noWait: true, + }); + setBaselineFormData(formData); + setError(null); + } finally { + setUpdating(false); + } + }, + { + startMessage: t('Updating namespace {{ name }}…', { name: projectId }), + cancelledMessage: t('Cancelled update of namespace {{ name }}.', { name: projectId }), + successMessage: t('Updated namespace {{ name }}.', { name: projectId }), + errorMessage: t('Failed to update namespace {{ name }}.', { name: projectId }), + startOptions: { autoHideDuration: null }, + } + ); + }, [resourceGroup, clusterName, projectId, subscription, formData, t]); return { loading, diff --git a/plugins/aks-desktop/src/utils/azure/az-namespaces.ts b/plugins/aks-desktop/src/utils/azure/az-namespaces.ts index 3aad10a78..5904ad3dd 100644 --- a/plugins/aks-desktop/src/utils/azure/az-namespaces.ts +++ b/plugins/aks-desktop/src/utils/azure/az-namespaces.ts @@ -218,6 +218,24 @@ export async function updateManagedNamespace(options: { maybePush('--ingress-policy', ingressPolicy as string | undefined); maybePush('--egress-policy', egressPolicy as string | undefined); + // Re-pass existing labels to pass into az namespace update command + // so the ARM PUT doesn't wipe them. + try { + const current = await getManagedNamespaceDetails({ + clusterName, + resourceGroup, + namespaceName, + subscriptionId, + }); + const labels = current?.properties?.labels ?? current?.labels; + const entries = labels && typeof labels === 'object' ? Object.entries(labels) : []; + if (entries.length > 0) { + args.push('--labels', ...entries.map(([k, v]) => `${k}=${v}`)); + } + } catch { + // Proceed without labels if the fetch fails. + } + if (subscriptionId) { args.push('--subscription', subscriptionId); }