diff --git a/apps/app-frontend/src/shell/StorageRoutes.test.tsx b/apps/app-frontend/src/shell/StorageRoutes.test.tsx index 7592d342..f60c0d85 100644 --- a/apps/app-frontend/src/shell/StorageRoutes.test.tsx +++ b/apps/app-frontend/src/shell/StorageRoutes.test.tsx @@ -18,7 +18,7 @@ describe('StorageRoutes', () => { it('redirects the bare /admin/infrastructure/storage path to the Backends tab', () => { renderAt('/admin/infrastructure/storage'); - expect(screen.getByRole('tabpanel')).toHaveTextContent('Storage backends'); + expect(screen.getByRole('button', { name: 'Create backend' })).toBeInTheDocument(); }); it('renders a placeholder for backends/create', () => { diff --git a/libs/i18n/locales/en/translation.json b/libs/i18n/locales/en/translation.json index ebe4f6ed..f8ac1767 100644 --- a/libs/i18n/locales/en/translation.json +++ b/libs/i18n/locales/en/translation.json @@ -124,6 +124,7 @@ "CPU cores": "CPU cores", "Create": "Create", "Create an instance type to start defining provider-managed sizes.": "Create an instance type to start defining provider-managed sizes.", + "Create backend": "Create backend", "Create cluster": "Create cluster", "Create cluster wizard": "Create cluster wizard", "Create identity provider": "Create identity provider", @@ -174,6 +175,7 @@ "Editable fields can be changed when creating from this catalog item. Fixed fields use the default value shown.": "Editable fields can be changed when creating from this catalog item. Fixed fields use the default value shown.", "Enable": "Enable", "Enable {{idpName}}?": "Enable {{idpName}}?", + "Endpoint": "Endpoint", "Error": "Error", "Error loading external IP pools": "Error loading external IP pools", "Error loading virtual networks": "Error loading virtual networks", @@ -199,6 +201,7 @@ "Failed to delete cluster": "Failed to delete cluster", "Failed to delete compute instance": "Failed to delete compute instance", "Failed to delete Identity provider": "Failed to delete Identity provider", + "Failed to delete storage backend": "Failed to delete storage backend", "Failed to delete storage tier": "Failed to delete storage tier", "Failed to delete tenant": "Failed to delete tenant", "Failed to disable Identity provider": "Failed to disable Identity provider", @@ -298,6 +301,7 @@ "No published catalog items are available yet.": "No published catalog items are available yet.", "No security groups match your search.": "No security groups match your search.", "No security groups yet. Create one to get started.": "No security groups yet. Create one to get started.", + "No storage backends yet. Create one to get started.": "No storage backends yet. Create one to get started.", "No storage tiers yet. Create one to get started.": "No storage tiers yet. Create one to get started.", "No subnets yet. Create one to get started.": "No subnets yet. Create one to get started.", "No tenants match your search.": "No tenants match your search.", @@ -338,6 +342,7 @@ "Protocol": "Protocol", "Protocol is required": "Protocol is required", "Protocol(s)": "Protocol(s)", + "Provider": "Provider", "Provision a bare metal instance from a catalog item.": "Provision a bare metal instance from a catalog item.", "Provision bare metal": "Provision bare metal", "Provisioning": "Provisioning", @@ -410,6 +415,7 @@ "This permanently deletes the cluster and all its resources. This action cannot be undone.": "This permanently deletes the cluster and all its resources. This action cannot be undone.", "This permanently deletes the compute instance. This action cannot be undone.": "This permanently deletes the compute instance. This action cannot be undone.", "This permanently deletes the Identity provider and all its resources. This action cannot be undone.": "This permanently deletes the Identity provider and all its resources. This action cannot be undone.", + "This permanently deletes the storage backend. This action cannot be undone.": "This permanently deletes the storage backend. This action cannot be undone.", "This permanently deletes the storage tier. This action cannot be undone.": "This permanently deletes the storage tier. This action cannot be undone.", "This permanently deletes the tenant and all its resources. This action cannot be undone.": "This permanently deletes the tenant and all its resources. This action cannot be undone.", "This will permanently delete the rule. This action cannot be undone. Traffic matching this rule will be blocked.": "This will permanently delete the rule. This action cannot be undone. Traffic matching this rule will be blocked.", diff --git a/libs/ui-components/src/components/Storage/StorageBackendActionsMenu.tsx b/libs/ui-components/src/components/Storage/StorageBackendActionsMenu.tsx new file mode 100644 index 00000000..0ebcbe6d --- /dev/null +++ b/libs/ui-components/src/components/Storage/StorageBackendActionsMenu.tsx @@ -0,0 +1,69 @@ +import { useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { Dropdown, DropdownItem, DropdownList, MenuToggle } from '@patternfly/react-core'; +import { EllipsisVIcon } from '@patternfly/react-icons/dist/esm/icons/ellipsis-v-icon'; + +import type { StorageBackend } from '@osac/types/private'; + +import StorageBackendDeleteConfirmModal from './StorageBackendDeleteConfirmModal'; +import { useTranslation } from '../../hooks/useTranslation'; + +interface StorageBackendActionsMenuProps { + backend: StorageBackend; +} + +const StorageBackendActionsMenu = ({ backend }: StorageBackendActionsMenuProps) => { + const { t } = useTranslation(); + const navigate = useNavigate(); + const [open, setOpen] = useState(false); + const [deleteOpen, setDeleteOpen] = useState(false); + + const backendName = backend.metadata?.name ?? backend.id; + + return ( + <> + {deleteOpen && ( + setDeleteOpen(false)} + onSuccess={() => setDeleteOpen(false)} + /> + )} + ( + setOpen((o) => !o)} + isExpanded={open} + aria-label={t('Actions for {{name}}', { name: backendName })} + > + + + )} + popperProps={{ position: 'right' }} + > + + navigate(`/admin/infrastructure/storage/backends/${backend.id}/edit`)} + > + {t('Edit')} + + { + setDeleteOpen(true); + setOpen(false); + }} + > + {t('Delete')} + + + + + ); +}; + +export default StorageBackendActionsMenu; diff --git a/libs/ui-components/src/components/Storage/StorageBackendDeleteConfirmModal.test.tsx b/libs/ui-components/src/components/Storage/StorageBackendDeleteConfirmModal.test.tsx new file mode 100644 index 00000000..f889f96b --- /dev/null +++ b/libs/ui-components/src/components/Storage/StorageBackendDeleteConfirmModal.test.tsx @@ -0,0 +1,107 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import StorageBackendDeleteConfirmModal from './StorageBackendDeleteConfirmModal'; +import * as storageBackendsApi from '../../api/v1/private/storage-backends'; + +vi.mock('../../api/v1/private/storage-backends', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useDeleteStorageBackend: vi.fn(), + }; +}); + +const mockBackend = { + id: 'backend-1', + metadata: { name: 'vast-prod' }, + spec: { provider: 'vast', endpoint: 'vast.example.com' }, +}; + +describe('StorageBackendDeleteConfirmModal', () => { + const mutate = vi.fn(); + const reset = vi.fn(); + + beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(storageBackendsApi.useDeleteStorageBackend).mockReturnValue({ + mutate, + reset, + isPending: false, + error: null, + } as unknown as ReturnType); + }); + + it('deletes the backend and calls onSuccess', async () => { + const user = userEvent.setup(); + mutate.mockImplementation((_id: string, options?: { onSuccess?: () => void }) => { + options?.onSuccess?.(); + return Promise.resolve(undefined); + }); + const onSuccess = vi.fn(); + + render( + , + ); + + expect(screen.getByRole('dialog')).toBeInTheDocument(); + + await user.click(screen.getByRole('button', { name: /^Delete$/i })); + + await waitFor(() => { + expect(mutate).toHaveBeenCalledWith('backend-1', { + onSuccess: expect.any(Function) as unknown, + }); + expect(onSuccess).toHaveBeenCalled(); + }); + }); + + it('shows the FAILED_PRECONDITION error verbatim and does not call onSuccess', async () => { + const user = userEvent.setup(); + vi.mocked(storageBackendsApi.useDeleteStorageBackend).mockReturnValue({ + mutate, + reset, + isPending: false, + error: new Error('[failed_precondition] backend is referenced by an active storage tier'), + } as unknown as ReturnType); + const onSuccess = vi.fn(); + + render( + , + ); + + await user.click(screen.getByRole('button', { name: /^Delete$/i })); + + await waitFor(() => { + expect( + screen.getByText(/backend is referenced by an active storage tier/i), + ).toBeInTheDocument(); + }); + expect(onSuccess).not.toHaveBeenCalled(); + }); + + it('calls onClose when Cancel is clicked', async () => { + const user = userEvent.setup(); + const onClose = vi.fn(); + + render( + , + ); + + await user.click(screen.getByRole('button', { name: /Cancel/i })); + expect(onClose).toHaveBeenCalled(); + }); +}); diff --git a/libs/ui-components/src/components/Storage/StorageBackendDeleteConfirmModal.tsx b/libs/ui-components/src/components/Storage/StorageBackendDeleteConfirmModal.tsx new file mode 100644 index 00000000..82d8d22a --- /dev/null +++ b/libs/ui-components/src/components/Storage/StorageBackendDeleteConfirmModal.tsx @@ -0,0 +1,77 @@ +import { + Alert, + Button, + Modal, + ModalBody, + ModalFooter, + ModalHeader, + Stack, + StackItem, +} from '@patternfly/react-core'; + +import type { StorageBackend } from '@osac/types/private'; + +import { useDeleteStorageBackend } from '../../api/v1/private/storage-backends'; +import { useTranslation } from '../../hooks/useTranslation'; +import { getErrorMessage } from '../../utils/error'; + +interface StorageBackendDeleteConfirmModalProps { + backend: StorageBackend; + onClose: () => void; + onSuccess: () => void; +} + +const StorageBackendDeleteConfirmModal = ({ + backend, + onClose, + onSuccess, +}: StorageBackendDeleteConfirmModalProps) => { + const { t } = useTranslation(); + const { mutate, isPending, error } = useDeleteStorageBackend(); + + const backendName = backend.metadata?.name ?? backend.id; + + return ( + + + + + + {t('This permanently deletes the storage backend. This action cannot be undone.')} + + {error && ( + + + {getErrorMessage(error)} + + + )} + + + + + + + + ); +}; + +export default StorageBackendDeleteConfirmModal; diff --git a/libs/ui-components/src/components/Storage/StorageBackendStatusLabel.test.tsx b/libs/ui-components/src/components/Storage/StorageBackendStatusLabel.test.tsx new file mode 100644 index 00000000..315e5f5c --- /dev/null +++ b/libs/ui-components/src/components/Storage/StorageBackendStatusLabel.test.tsx @@ -0,0 +1,24 @@ +import { screen } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; + +import { StorageBackendState } from '@osac/types/private'; + +import StorageBackendStatusLabel from './StorageBackendStatusLabel'; +import { renderWithProviders } from '../../test-utils/TestProviders'; + +describe('StorageBackendStatusLabel', () => { + it('maps READY to ready/Ready', () => { + renderWithProviders(); + expect(screen.getByText('Ready')).toBeInTheDocument(); + }); + + it('maps UNSPECIFIED to unspecified/Unspecified', () => { + renderWithProviders(); + expect(screen.getByText('Unspecified')).toBeInTheDocument(); + }); + + it('maps undefined to unspecified/Unspecified', () => { + renderWithProviders(); + expect(screen.getByText('Unspecified')).toBeInTheDocument(); + }); +}); diff --git a/libs/ui-components/src/components/Storage/StorageBackendStatusLabel.tsx b/libs/ui-components/src/components/Storage/StorageBackendStatusLabel.tsx new file mode 100644 index 00000000..a3baea3d --- /dev/null +++ b/libs/ui-components/src/components/Storage/StorageBackendStatusLabel.tsx @@ -0,0 +1,34 @@ +import type { TFunction } from 'i18next'; + +import { StorageBackendState } from '@osac/types/private'; + +import { useTranslation } from '../../hooks/useTranslation'; +import { ResourceStatusLabel, StatusLabelProps } from '../Resource/ResourceStatusLabel'; + +interface StorageBackendStatusLabelProps { + state?: StorageBackendState; +} + +const storageBackendStatusMap = (t: TFunction): Record => ({ + [StorageBackendState.READY]: { + status: 'ready', + text: t('Ready'), + }, + [StorageBackendState.UNSPECIFIED]: { + status: 'unspecified', + text: t('Unspecified'), + }, +}); + +const StorageBackendStatusLabel = ({ state }: StorageBackendStatusLabelProps) => { + const { t } = useTranslation(); + + const statusMap = storageBackendStatusMap(t); + + const status = + state !== undefined ? statusMap[state] : statusMap[StorageBackendState.UNSPECIFIED]; + + return ; +}; + +export default StorageBackendStatusLabel; diff --git a/libs/ui-components/src/components/Storage/StorageBackendsTable.tsx b/libs/ui-components/src/components/Storage/StorageBackendsTable.tsx new file mode 100644 index 00000000..a8314a6d --- /dev/null +++ b/libs/ui-components/src/components/Storage/StorageBackendsTable.tsx @@ -0,0 +1,44 @@ +import { Table, Tbody, Td, Th, Thead, Tr } from '@patternfly/react-table'; + +import type { StorageBackend } from '@osac/types/private'; + +import StorageBackendActionsMenu from './StorageBackendActionsMenu'; +import StorageBackendStatusLabel from './StorageBackendStatusLabel'; +import { useTranslation } from '../../hooks/useTranslation'; + +interface StorageBackendsTableProps { + backends: StorageBackend[]; +} + +export const StorageBackendsTable = ({ backends }: StorageBackendsTableProps) => { + const { t } = useTranslation(); + + return ( + + + + + + + + + + + {backends.map((backend) => ( + + + + + + + + ))} + +
{t('Name')}{t('Status')}{t('Provider')}{t('Endpoint')} +
{backend.metadata?.name || backend.id} + + {backend.spec?.provider}{backend.spec?.endpoint} + +
+ ); +}; diff --git a/libs/ui-components/src/pages/admin/StorageBackendsListPage.test.tsx b/libs/ui-components/src/pages/admin/StorageBackendsListPage.test.tsx new file mode 100644 index 00000000..a9ae561e --- /dev/null +++ b/libs/ui-components/src/pages/admin/StorageBackendsListPage.test.tsx @@ -0,0 +1,140 @@ +import { screen, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { StorageBackend } from '@osac/types/private'; +import { StorageBackendState } from '@osac/types/private'; + +import { StorageBackendsListPage } from './StorageBackendsListPage'; +import { renderWithProviders } from '../../test-utils/TestProviders'; + +const mockNavigate = vi.fn(); +vi.mock('react-router-dom', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useNavigate: () => mockNavigate, + }; +}); + +const makeBackend = ( + id: string, + name: string, + provider: string, + endpoint: string, + state?: StorageBackendState, +) => + ({ + id, + metadata: { name }, + spec: { provider, endpoint }, + status: state !== undefined ? { state } : undefined, + }) as StorageBackend; + +const defaultBackends = [ + makeBackend('b-1', 'vast-prod', 'vast', 'vast.example.com', StorageBackendState.READY), + makeBackend('b-2', 'ceph-dev', 'ceph', 'ceph.example.com', StorageBackendState.UNSPECIFIED), +]; + +const renderPage = (storageBackends: StorageBackend[] = defaultBackends) => + renderWithProviders(, { apiFixtures: { storageBackends } }); + +describe('StorageBackendsListPage', () => { + beforeEach(() => { + mockNavigate.mockReset(); + }); + + it('renders column headers', async () => { + renderPage(); + + await waitFor(() => { + expect(screen.getByRole('columnheader', { name: 'Name' })).toBeInTheDocument(); + }); + expect(screen.getByRole('columnheader', { name: 'Provider' })).toBeInTheDocument(); + expect(screen.getByRole('columnheader', { name: 'Endpoint' })).toBeInTheDocument(); + expect(screen.getByRole('columnheader', { name: 'Status' })).toBeInTheDocument(); + }); + + it('renders a row per backend with name, provider, and endpoint', async () => { + renderPage(); + + await waitFor(() => { + expect(screen.getByText('vast-prod')).toBeInTheDocument(); + }); + expect(screen.getByText('vast')).toBeInTheDocument(); + expect(screen.getByText('vast.example.com')).toBeInTheDocument(); + expect(screen.getByText('ceph-dev')).toBeInTheDocument(); + }); + + it('renders status labels for each backend', async () => { + renderPage(); + + await waitFor(() => { + expect(screen.getByText('Ready')).toBeInTheDocument(); + }); + expect(screen.getByText('Unspecified')).toBeInTheDocument(); + }); + + it('shows empty state when there are no backends', async () => { + renderPage([]); + + await waitFor(() => { + expect( + screen.getByText('No storage backends yet. Create one to get started.'), + ).toBeInTheDocument(); + }); + expect(screen.queryByRole('table')).not.toBeInTheDocument(); + }); + + it('navigates to the create route when "Create backend" is clicked', async () => { + const { user } = renderPage(); + + await user.click(screen.getByRole('button', { name: 'Create backend' })); + + expect(mockNavigate).toHaveBeenCalledWith('/admin/infrastructure/storage/backends/create'); + }); + + it('navigates to the edit route with the correct ID when Edit is clicked', async () => { + const { user } = renderPage(); + + await waitFor(() => { + expect(screen.getByText('vast-prod')).toBeInTheDocument(); + }); + + await user.click(screen.getByRole('button', { name: 'Actions for vast-prod' })); + await user.click(screen.getByText('Edit')); + + expect(mockNavigate).toHaveBeenCalledWith('/admin/infrastructure/storage/backends/b-1/edit'); + }); + + it('opens the delete confirmation dialog when Delete is clicked', async () => { + const { user } = renderPage(); + + await waitFor(() => { + expect(screen.getByText('vast-prod')).toBeInTheDocument(); + }); + + await user.click(screen.getByRole('button', { name: 'Actions for vast-prod' })); + await user.click(screen.getByText('Delete')); + + expect(screen.getByRole('dialog')).toBeInTheDocument(); + expect(screen.getByText('Delete vast-prod?')).toBeInTheDocument(); + }); + + it('removes the row from the table after a successful delete', async () => { + const { user } = renderPage(); + + await waitFor(() => { + expect(screen.getByText('vast-prod')).toBeInTheDocument(); + }); + + await user.click(screen.getByRole('button', { name: 'Actions for vast-prod' })); + await user.click(screen.getByText('Delete')); + + await user.click(screen.getByRole('button', { name: /^Delete$/i })); + + await waitFor(() => { + expect(screen.queryByText('vast-prod')).not.toBeInTheDocument(); + }); + expect(screen.getByText('ceph-dev')).toBeInTheDocument(); + }); +}); diff --git a/libs/ui-components/src/pages/admin/StorageBackendsListPage.tsx b/libs/ui-components/src/pages/admin/StorageBackendsListPage.tsx new file mode 100644 index 00000000..e303eee8 --- /dev/null +++ b/libs/ui-components/src/pages/admin/StorageBackendsListPage.tsx @@ -0,0 +1,39 @@ +import { useNavigate } from 'react-router-dom'; +import { Button, Flex, FlexItem } from '@patternfly/react-core'; + +import { usePrivateStorageBackends } from '@osac/ui-components/api/v1/private/storage-backends'; +import ListPageBody from '@osac/ui-components/components/Page/ListPageBody'; +import { StorageBackendsTable } from '@osac/ui-components/components/Storage/StorageBackendsTable'; +import { SubtleContent } from '@osac/ui-components/components/SubtleContent/SubtleContent'; +import { useTranslation } from '@osac/ui-components/hooks/useTranslation'; + +export const StorageBackendsListPage = () => { + const { t } = useTranslation(); + const navigate = useNavigate(); + + const { data: backends = [], isLoading, error } = usePrivateStorageBackends(); + + return ( + <> + + + + + + + {backends.length === 0 ? ( + + {t('No storage backends yet. Create one to get started.')} + + ) : ( + + )} + + + ); +}; diff --git a/libs/ui-components/src/pages/admin/StorageManagementPage.test.tsx b/libs/ui-components/src/pages/admin/StorageManagementPage.test.tsx index 522c3020..7fea688d 100644 --- a/libs/ui-components/src/pages/admin/StorageManagementPage.test.tsx +++ b/libs/ui-components/src/pages/admin/StorageManagementPage.test.tsx @@ -28,10 +28,14 @@ describe('StorageManagementPage', () => { expect(screen.getByRole('tab', { name: 'Tiers' })).toBeInTheDocument(); }); - it('shows the Backends placeholder when activeTab is backends', () => { + it('shows the storage backends list when activeTab is backends', async () => { renderPage('backends'); - expect(screen.getByRole('tabpanel')).toHaveTextContent('Storage backends'); + await waitFor(() => { + expect( + screen.getByText('No storage backends yet. Create one to get started.'), + ).toBeInTheDocument(); + }); }); it('renders the Storage Tiers list page when activeTab is tiers', async () => { @@ -69,7 +73,7 @@ describe('StorageManagementPage', () => { ); await waitFor(() => { - expect(screen.getByRole('tabpanel')).toHaveTextContent('Storage backends'); + expect(screen.getByRole('button', { name: 'Create backend' })).toBeInTheDocument(); }); expect(onStorageTierList).not.toHaveBeenCalled(); }); @@ -102,7 +106,7 @@ describe('StorageManagementPage', () => { await user.click(screen.getByRole('tab', { name: 'Backends' })); await waitFor(() => { - expect(screen.getByRole('tabpanel')).toHaveTextContent('Storage backends'); + expect(screen.getByRole('button', { name: 'Create backend' })).toBeInTheDocument(); }); expect(screen.queryByRole('button', { name: 'Create tier' })).not.toBeInTheDocument(); expect(onStorageTierList).toHaveBeenCalledTimes(1); diff --git a/libs/ui-components/src/pages/admin/StorageManagementPage.tsx b/libs/ui-components/src/pages/admin/StorageManagementPage.tsx index 62f377e9..0df40e6d 100644 --- a/libs/ui-components/src/pages/admin/StorageManagementPage.tsx +++ b/libs/ui-components/src/pages/admin/StorageManagementPage.tsx @@ -5,7 +5,7 @@ import ListPage from '@osac/ui-components/components/Page/ListPage'; import ListPageBody from '@osac/ui-components/components/Page/ListPageBody'; import { useTranslation } from '@osac/ui-components/hooks/useTranslation'; -import { StoragePlaceholder } from './StoragePlaceholder'; +import { StorageBackendsListPage } from './StorageBackendsListPage'; import { StorageTiersListPage } from './StorageTiersListPage'; type StorageTab = 'backends' | 'tiers'; @@ -35,7 +35,7 @@ export const StorageManagementPage = ({ activeTab }: { activeTab: StorageTab }) unmountOnExit > {t('Backends')}}> - + {t('Tiers')}}> diff --git a/libs/ui-components/src/test-utils/createMockConnectTransport.ts b/libs/ui-components/src/test-utils/createMockConnectTransport.ts index 0aa90374..0f29794f 100644 --- a/libs/ui-components/src/test-utils/createMockConnectTransport.ts +++ b/libs/ui-components/src/test-utils/createMockConnectTransport.ts @@ -184,7 +184,7 @@ export const createMockConnectTransport = ( const securityGroups = fixtures.securityGroups ?? []; const instanceTypes = fixtures.instanceTypes ?? []; const privateInstanceTypes = fixtures.privateInstanceTypes ?? []; - const storageBackends = fixtures.storageBackends ?? []; + const storageBackends = [...(fixtures.storageBackends ?? [])]; const storageTiers = fixtures.storageTiers ?? []; return wrapWithAuthInterceptor( @@ -339,7 +339,13 @@ export const createMockConnectTransport = ( } return { object: req.object }; }, - delete: () => ({}), + delete: (req) => { + const index = storageBackends.findIndex((b) => b.id === req.id); + if (index !== -1) { + storageBackends.splice(index, 1); + } + return {}; + }, }); router.service(StorageTiers, {