diff --git a/apps/app-frontend/src/shell/StorageRoutes.test.tsx b/apps/app-frontend/src/shell/StorageRoutes.test.tsx index 0775d7ee..7592d342 100644 --- a/apps/app-frontend/src/shell/StorageRoutes.test.tsx +++ b/apps/app-frontend/src/shell/StorageRoutes.test.tsx @@ -1,5 +1,5 @@ import { Route, Routes } from 'react-router-dom'; -import { screen } from '@testing-library/react'; +import { screen, waitFor } from '@testing-library/react'; import { describe, expect, it } from 'vitest'; import { renderWithProviders } from '@osac/ui-components/test-utils/TestProviders'; @@ -33,9 +33,23 @@ describe('StorageRoutes', () => { expect(screen.getByText('Edit storage backend')).toBeInTheDocument(); }); - it('renders the Tiers tab at /admin/infrastructure/storage/tiers', () => { + it('renders the Tiers tab at /admin/infrastructure/storage/tiers', async () => { renderAt('/admin/infrastructure/storage/tiers'); - expect(screen.getByRole('tabpanel')).toHaveTextContent('Storage tiers'); + await waitFor(() => { + expect(screen.getByRole('button', { name: 'Create tier' })).toBeInTheDocument(); + }); + }); + + it('renders a placeholder for tiers/create', () => { + renderAt('/admin/infrastructure/storage/tiers/create'); + + expect(screen.getByText('Create storage tier')).toBeInTheDocument(); + }); + + it('renders a placeholder for tiers/:id/edit', () => { + renderAt('/admin/infrastructure/storage/tiers/tier-123/edit'); + + expect(screen.getByText('Edit storage tier')).toBeInTheDocument(); }); }); diff --git a/apps/app-frontend/src/shell/StorageRoutes.tsx b/apps/app-frontend/src/shell/StorageRoutes.tsx index 108a974b..7abe10fd 100644 --- a/apps/app-frontend/src/shell/StorageRoutes.tsx +++ b/apps/app-frontend/src/shell/StorageRoutes.tsx @@ -20,6 +20,14 @@ export const StorageRoutes = () => { element={} /> } /> + } + /> + } + /> ); }; diff --git a/libs/i18n/locales/en/translation.json b/libs/i18n/locales/en/translation.json index 01d8113d..ebe4f6ed 100644 --- a/libs/i18n/locales/en/translation.json +++ b/libs/i18n/locales/en/translation.json @@ -20,6 +20,7 @@ "Authorization URL": "Authorization URL", "Authorization URL is required": "Authorization URL is required", "Back": "Back", + "Backend IDs are shown in place of names until this recovers. This is separate from the normal fallback shown when a tier references a backend that no longer exists.": "Backend IDs are shown in place of names until this recovers. This is separate from the normal fallback shown when a tier references a backend that no longer exists.", "Backends": "Backends", "Bare Metal": "Bare Metal", "bare metal instance": "bare metal instance", @@ -27,6 +28,7 @@ "Bare metal instances": "Bare metal instances", "Bare Metal Machines": "Bare Metal Machines", "Bare metal provisioning wizard": "Bare metal provisioning wizard", + "Block": "Block", "Boot disk": "Boot disk", "Break-glass credentials": "Break-glass credentials", "Browse catalog items and launch virtual machines, clusters, or bare metal machines from published offerings.": "Browse catalog items and launch virtual machines, clusters, or bare metal machines from published offerings.", @@ -130,8 +132,10 @@ "Create instance type": "Create instance type", "Create security group": "Create security group", "Create storage backend": "Create storage backend", + "Create storage tier": "Create storage tier", "Create subnet": "Create subnet", "Create tenant": "Create tenant", + "Create tier": "Create tier", "Create virtual machine": "Create virtual machine", "Create virtual network": "Create virtual network", "Created": "Created", @@ -165,6 +169,7 @@ "Edit Identity provider": "Edit Identity provider", "Edit rule": "Edit rule", "Edit storage backend": "Edit storage backend", + "Edit storage tier": "Edit storage tier", "Editable": "Editable", "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", @@ -194,6 +199,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 tier": "Failed to delete storage tier", "Failed to delete tenant": "Failed to delete tenant", "Failed to disable Identity provider": "Failed to disable Identity provider", "Failed to download kubeconfig": "Failed to download kubeconfig", @@ -276,6 +282,7 @@ "Network class is required": "Network class is required", "Networking": "Networking", "Next": "Next", + "NFS": "NFS", "No bare metal instances match your search.": "No bare metal instances match your search.", "No bare metal instances yet.": "No bare metal instances yet.", "No catalog items found": "No catalog items found", @@ -291,6 +298,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 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.", "No tenants yet. Register one to get started.": "No tenants yet. Register one to get started.", @@ -329,6 +337,7 @@ "Primary domain": "Primary domain", "Protocol": "Protocol", "Protocol is required": "Protocol is required", + "Protocol(s)": "Protocol(s)", "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", @@ -401,6 +410,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 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.", "This will permanently delete the security group and all its rules. This action cannot be undone.": "This will permanently delete the security group and all its rules. This action cannot be undone.", @@ -411,6 +421,7 @@ "Token URL is required": "Token URL is required", "Type": "Type", "UDP": "UDP", + "Unable to resolve backend names": "Unable to resolve backend names", "Unauthorized": "Unauthorized", "Unknown": "Unknown", "Unspecified": "Unspecified", diff --git a/libs/ui-components/src/api/v1/private/storage-backends.ts b/libs/ui-components/src/api/v1/private/storage-backends.ts index 87ec175a..0b95eb0d 100644 --- a/libs/ui-components/src/api/v1/private/storage-backends.ts +++ b/libs/ui-components/src/api/v1/private/storage-backends.ts @@ -20,12 +20,20 @@ export const STORAGE_BACKEND_READY_LIST_FILTER = `this.status.state == ${Storage export const storageBackendIdsFilter = (ids: string[]): string => `this.id in [${ids.map((id) => `"${escapeCelStringLiteral(id)}"`).join(', ')}]`; -export const usePrivateStorageBackends = (params: ListParams = {}) => { +type StorageBackendsListOptions = { + enabled?: boolean; +}; + +export const usePrivateStorageBackends = ( + params: ListParams = {}, + options: StorageBackendsListOptions = {}, +) => { const client = useApiFetch(StorageBackends); return useApiQuery({ queryKey: apiQueryKey('v1/private/storage_backends', undefined, params), queryFn: () => client.list(params), select: (data) => data.items, + enabled: options.enabled ?? true, }); }; diff --git a/libs/ui-components/src/components/Storage/StorageTierActionsMenu.tsx b/libs/ui-components/src/components/Storage/StorageTierActionsMenu.tsx new file mode 100644 index 00000000..97e92154 --- /dev/null +++ b/libs/ui-components/src/components/Storage/StorageTierActionsMenu.tsx @@ -0,0 +1,65 @@ +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 { StorageTier } from '@osac/types/private'; + +import StorageTierDeleteConfirmModal from './StorageTierDeleteConfirmModal'; +import { useTranslation } from '../../hooks/useTranslation'; + +interface StorageTierActionsMenuProps { + tier: StorageTier; +} + +const StorageTierActionsMenu = ({ tier }: StorageTierActionsMenuProps) => { + const { t } = useTranslation(); + const navigate = useNavigate(); + const [open, setOpen] = useState(false); + const [deleteOpen, setDeleteOpen] = useState(false); + + return ( + <> + {deleteOpen && ( + setDeleteOpen(false)} + onSuccess={() => setDeleteOpen(false)} + /> + )} + ( + setOpen((o) => !o)} + aria-label={t('Actions for {{name}}', { name: tier.metadata?.name ?? tier.id })} + > + + + )} + popperProps={{ position: 'right' }} + > + + navigate(`/admin/infrastructure/storage/tiers/${tier.id}/edit`)} + > + {t('Edit')} + + { + setDeleteOpen(true); + setOpen(false); + }} + > + {t('Delete')} + + + + + ); +}; + +export default StorageTierActionsMenu; diff --git a/libs/ui-components/src/components/Storage/StorageTierDeleteConfirmModal.test.tsx b/libs/ui-components/src/components/Storage/StorageTierDeleteConfirmModal.test.tsx new file mode 100644 index 00000000..a9a717b1 --- /dev/null +++ b/libs/ui-components/src/components/Storage/StorageTierDeleteConfirmModal.test.tsx @@ -0,0 +1,106 @@ +import { Code, ConnectError } from '@connectrpc/connect'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import StorageTierDeleteConfirmModal from './StorageTierDeleteConfirmModal'; +import * as storageTiersApi from '../../api/v1/private/storage-tiers'; + +vi.mock('../../api/v1/private/storage-tiers', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useDeleteStorageTier: vi.fn(), + }; +}); + +const mockTier = { + id: 'tier-1', + metadata: { name: 'fast' }, + spec: { description: '', backends: [] }, +}; + +describe('StorageTierDeleteConfirmModal', () => { + const mutate = vi.fn(); + const reset = vi.fn(); + + beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(storageTiersApi.useDeleteStorageTier).mockReturnValue({ + mutate, + reset, + isPending: false, + error: null, + } as unknown as ReturnType); + }); + + it('deletes the tier 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('tier-1', { + onSuccess: expect.any(Function) as unknown, + }); + expect(onSuccess).toHaveBeenCalled(); + }); + }); + + it('shows the FAILED_PRECONDITION error verbatim and does not call onSuccess when the tier is referenced by a Tenant', async () => { + const user = userEvent.setup(); + vi.mocked(storageTiersApi.useDeleteStorageTier).mockReturnValue({ + mutate, + reset, + isPending: false, + error: new ConnectError('Storage tier is referenced by a Tenant', Code.FailedPrecondition), + } as unknown as ReturnType); + const onSuccess = vi.fn(); + + render( + , + ); + + await user.click(screen.getByRole('button', { name: /^Delete$/i })); + + await waitFor(() => { + expect(screen.getByText('Storage tier is referenced by a Tenant')).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/StorageTierDeleteConfirmModal.tsx b/libs/ui-components/src/components/Storage/StorageTierDeleteConfirmModal.tsx new file mode 100644 index 00000000..1c11ce33 --- /dev/null +++ b/libs/ui-components/src/components/Storage/StorageTierDeleteConfirmModal.tsx @@ -0,0 +1,77 @@ +import { + Alert, + Button, + Modal, + ModalBody, + ModalFooter, + ModalHeader, + Stack, + StackItem, +} from '@patternfly/react-core'; + +import type { StorageTier } from '@osac/types/private'; + +import { useDeleteStorageTier } from '../../api/v1/private/storage-tiers'; +import { useTranslation } from '../../hooks/useTranslation'; +import { getErrorMessage } from '../../utils/error'; + +interface StorageTierDeleteConfirmModalProps { + tier: StorageTier; + onClose: () => void; + onSuccess: () => void; +} + +const StorageTierDeleteConfirmModal = ({ + tier, + onClose, + onSuccess, +}: StorageTierDeleteConfirmModalProps) => { + const { t } = useTranslation(); + const { mutate, isPending, error } = useDeleteStorageTier(); + + const tierName = tier.metadata?.name ?? tier.id; + + return ( + + + + + + {t('This permanently deletes the storage tier. This action cannot be undone.')} + + {error && ( + + + {getErrorMessage(error)} + + + )} + + + + + + + + ); +}; + +export default StorageTierDeleteConfirmModal; diff --git a/libs/ui-components/src/components/Storage/StorageTierStatusLabel.test.tsx b/libs/ui-components/src/components/Storage/StorageTierStatusLabel.test.tsx new file mode 100644 index 00000000..f568c6a0 --- /dev/null +++ b/libs/ui-components/src/components/Storage/StorageTierStatusLabel.test.tsx @@ -0,0 +1,23 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; + +import { StorageTierState } from '@osac/types/private'; + +import { StorageTierStatusLabel } from './StorageTierStatusLabel'; + +describe('StorageTierStatusLabel', () => { + it('maps ACTIVE to ready/Active', () => { + render(); + expect(screen.getByText('Active')).toBeInTheDocument(); + }); + + it('maps undefined to unspecified/Unspecified', () => { + render(); + expect(screen.getByText('Unspecified')).toBeInTheDocument(); + }); + + it('maps UNSPECIFIED to unspecified/Unspecified', () => { + render(); + expect(screen.getByText('Unspecified')).toBeInTheDocument(); + }); +}); diff --git a/libs/ui-components/src/components/Storage/StorageTierStatusLabel.tsx b/libs/ui-components/src/components/Storage/StorageTierStatusLabel.tsx new file mode 100644 index 00000000..dc12faa5 --- /dev/null +++ b/libs/ui-components/src/components/Storage/StorageTierStatusLabel.tsx @@ -0,0 +1,25 @@ +import { StorageTierState } from '@osac/types/private'; + +import { ResourceStatusLabel, type StatusKind } from '../Resource/ResourceStatusLabel'; + +interface StorageTierStatusLabelProps { + state?: StorageTierState; +} + +const STORAGE_TIER_STATUS_MAP: Record = { + [StorageTierState.UNSPECIFIED]: { status: 'unspecified', text: 'Unspecified' }, + [StorageTierState.ACTIVE]: { status: 'ready', text: 'Active' }, +}; + +const resolveStorageTierStatus = ( + state?: StorageTierState, +): { status: StatusKind; text: string } => + state !== undefined && state in STORAGE_TIER_STATUS_MAP + ? STORAGE_TIER_STATUS_MAP[state] + : STORAGE_TIER_STATUS_MAP[StorageTierState.UNSPECIFIED]; + +export const StorageTierStatusLabel = ({ state }: StorageTierStatusLabelProps) => { + const { status, text } = resolveStorageTierStatus(state); + + return ; +}; diff --git a/libs/ui-components/src/pages/admin/StorageManagementPage.test.tsx b/libs/ui-components/src/pages/admin/StorageManagementPage.test.tsx index 7f7271c7..522c3020 100644 --- a/libs/ui-components/src/pages/admin/StorageManagementPage.test.tsx +++ b/libs/ui-components/src/pages/admin/StorageManagementPage.test.tsx @@ -1,6 +1,6 @@ import { Route, Routes } from 'react-router-dom'; import { screen, waitFor } from '@testing-library/react'; -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { StorageManagementPage } from './StorageManagementPage'; import { renderWithProviders } from '../../test-utils/TestProviders'; @@ -34,10 +34,12 @@ describe('StorageManagementPage', () => { expect(screen.getByRole('tabpanel')).toHaveTextContent('Storage backends'); }); - it('shows the Tiers placeholder when activeTab is tiers', () => { + it('renders the Storage Tiers list page when activeTab is tiers', async () => { renderPage('tiers'); - expect(screen.getByRole('tabpanel')).toHaveTextContent('Storage tiers'); + await waitFor(() => { + expect(screen.getByRole('button', { name: 'Create tier' })).toBeInTheDocument(); + }); }); it('navigates to /admin/infrastructure/storage/tiers when the Tiers tab is clicked', async () => { @@ -46,7 +48,63 @@ describe('StorageManagementPage', () => { await user.click(screen.getByRole('tab', { name: 'Tiers' })); await waitFor(() => { - expect(screen.getByRole('tabpanel')).toHaveTextContent('Storage tiers'); + expect(screen.getByRole('button', { name: 'Create tier' })).toBeInTheDocument(); + }); + }); + + it('does not mount the Tiers list page (and its data fetches) while the Backends tab is active', async () => { + const onStorageTierList = vi.fn(() => ({ items: [], size: 0, total: 0 })); + + renderWithProviders( + + } + /> + , + { + routerEntries: ['/admin/infrastructure/storage/backends'], + transportOverrides: { onStorageTierList }, + }, + ); + + await waitFor(() => { + expect(screen.getByRole('tabpanel')).toHaveTextContent('Storage backends'); + }); + expect(onStorageTierList).not.toHaveBeenCalled(); + }); + + it('unmounts the Tiers list page (and stops its data fetches) after switching away to the Backends tab', async () => { + const onStorageTierList = vi.fn(() => ({ items: [], size: 0, total: 0 })); + + const { user } = renderWithProviders( + + } + /> + } + /> + , + { + routerEntries: ['/admin/infrastructure/storage/tiers'], + transportOverrides: { onStorageTierList }, + }, + ); + + await waitFor(() => { + expect(screen.getByRole('button', { name: 'Create tier' })).toBeInTheDocument(); + }); + expect(onStorageTierList).toHaveBeenCalledTimes(1); + + await user.click(screen.getByRole('tab', { name: 'Backends' })); + + await waitFor(() => { + expect(screen.getByRole('tabpanel')).toHaveTextContent('Storage backends'); }); + 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 e0cd8563..62f377e9 100644 --- a/libs/ui-components/src/pages/admin/StorageManagementPage.tsx +++ b/libs/ui-components/src/pages/admin/StorageManagementPage.tsx @@ -6,6 +6,7 @@ import ListPageBody from '@osac/ui-components/components/Page/ListPageBody'; import { useTranslation } from '@osac/ui-components/hooks/useTranslation'; import { StoragePlaceholder } from './StoragePlaceholder'; +import { StorageTiersListPage } from './StorageTiersListPage'; type StorageTab = 'backends' | 'tiers'; @@ -30,12 +31,14 @@ export const StorageManagementPage = ({ activeTab }: { activeTab: StorageTab }) } }} aria-label={t('Storage tabs')} + mountOnEnter + unmountOnExit > {t('Backends')}}> {t('Tiers')}}> - + diff --git a/libs/ui-components/src/pages/admin/StorageTiersListPage.test.tsx b/libs/ui-components/src/pages/admin/StorageTiersListPage.test.tsx new file mode 100644 index 00000000..c3330dc6 --- /dev/null +++ b/libs/ui-components/src/pages/admin/StorageTiersListPage.test.tsx @@ -0,0 +1,257 @@ +import { Route, Routes } from 'react-router-dom'; +import { Code, ConnectError } from '@connectrpc/connect'; +import { screen, waitFor } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +import type { StorageBackend, StorageTier } from '@osac/types/private'; +import { StorageBackendState, StorageProtocol, StorageTierState } from '@osac/types/private'; + +import { StorageTiersListPage } from './StorageTiersListPage'; +import { storageBackendIdsFilter } from '../../api/v1/private/storage-backends'; +import { renderWithProviders } from '../../test-utils/TestProviders'; + +const makeBackend = (id: string, name: string): StorageBackend => + ({ + id, + metadata: { name }, + spec: { provider: 'vast', endpoint: `${id}.example.com`, credentials: {} }, + status: { state: StorageBackendState.READY }, + }) as StorageBackend; + +const makeTier = ( + id: string, + name: string, + backends: { backendId: string; protocol: StorageProtocol }[], +): StorageTier => + ({ + id, + metadata: { name }, + spec: { + description: '', + backends: backends.map((b) => ({ + backendId: b.backendId, + protocol: b.protocol, + maxReadBandwidthMbs: 0, + maxWriteBandwidthMbs: 0, + quotaGib: BigInt(0), + encryptionEnabled: false, + })), + }, + status: { state: StorageTierState.ACTIVE }, + }) as StorageTier; + +const backendA = makeBackend('backend-a', 'Fast NVMe'); +const backendB = makeBackend('backend-b', 'Bulk HDD'); +const backendUnused = makeBackend('backend-unused', 'Unused Backend'); + +const singleBackendTier = makeTier('tier-1', 'fast', [ + { backendId: 'backend-a', protocol: StorageProtocol.NFS }, +]); +const mixedTier = makeTier('tier-2', 'mixed', [ + { backendId: 'backend-b', protocol: StorageProtocol.BLOCK }, + { backendId: 'missing-backend', protocol: StorageProtocol.NFS }, +]); + +const defaultTiers = [singleBackendTier, mixedTier]; +const defaultBackends = [backendA, backendB, backendUnused]; + +const renderPage = ( + tiers: StorageTier[] = defaultTiers, + backends: StorageBackend[] = defaultBackends, +) => + renderWithProviders(, { + apiFixtures: { storageTiers: tiers, storageBackends: backends }, + }); + +describe('StorageTiersListPage', () => { + it('renders tier rows with name, resolved backend names, and protocols', async () => { + renderPage(); + + await waitFor(() => { + expect(screen.getByText('fast')).toBeInTheDocument(); + }); + expect(screen.getByText('Fast NVMe')).toBeInTheDocument(); + expect(screen.getByText('NFS')).toBeInTheDocument(); + }); + + it('renders comma-separated backend names and protocols for a tier with multiple backend associations, falling back to the raw id when a backend cannot be resolved', async () => { + renderPage(); + + await waitFor(() => { + expect(screen.getByText('mixed')).toBeInTheDocument(); + }); + expect(screen.getByText('Bulk HDD, missing-backend')).toBeInTheDocument(); + expect(screen.getByText('Block, NFS')).toBeInTheDocument(); + }); + + it('renders the STATUS column via StorageTierStatusLabel', async () => { + renderPage(); + + await waitFor(() => { + expect(screen.getAllByText('Active')).toHaveLength(2); + }); + }); + + it('requests exactly the set of backend ids referenced by the rendered tiers, not every registered backend', async () => { + let capturedFilter: string | undefined; + + renderWithProviders(, { + apiFixtures: { storageTiers: defaultTiers, storageBackends: defaultBackends }, + transportOverrides: { + onStorageBackendList: (req) => { + capturedFilter = req.filter; + return { + items: defaultBackends, + size: defaultBackends.length, + total: defaultBackends.length, + }; + }, + }, + }); + + await waitFor(() => { + expect(capturedFilter).toBeDefined(); + }); + + const expectedIds = ['backend-a', 'backend-b', 'missing-backend'].sort(); + expect(capturedFilter).toBe(storageBackendIdsFilter(expectedIds)); + }); + + it('does not request backend names when there are no tiers', async () => { + const onStorageBackendList = vi.fn(() => ({ items: [], size: 0, total: 0 })); + + renderWithProviders(, { + apiFixtures: { storageTiers: [], storageBackends: defaultBackends }, + transportOverrides: { onStorageBackendList }, + }); + + await waitFor(() => { + expect( + screen.getByText('No storage tiers yet. Create one to get started.'), + ).toBeInTheDocument(); + }); + expect(onStorageBackendList).not.toHaveBeenCalled(); + }); + + it('shows a warning banner when the backend-name lookup fails, without breaking the per-row id fallback', async () => { + renderWithProviders(, { + apiFixtures: { storageTiers: defaultTiers, storageBackends: defaultBackends }, + transportOverrides: { + onStorageBackendList: () => { + throw new ConnectError('backend service unavailable', Code.Unavailable); + }, + }, + }); + + await waitFor(() => { + expect(screen.getByText('Unable to resolve backend names')).toBeInTheDocument(); + }); + expect(screen.getByText('backend-a')).toBeInTheDocument(); + }); + + it('shows the empty state and no table when there are no tiers', async () => { + renderPage([], []); + + await waitFor(() => { + expect( + screen.getByText('No storage tiers yet. Create one to get started.'), + ).toBeInTheDocument(); + }); + expect(screen.queryByRole('table')).not.toBeInTheDocument(); + }); + + it('navigates to the create route when Create tier is clicked', async () => { + const { user } = renderWithProviders( + + } /> + navigated-to-create} + /> + , + { + apiFixtures: { storageTiers: defaultTiers, storageBackends: defaultBackends }, + routerEntries: ['/admin/infrastructure/storage/tiers'], + }, + ); + + await waitFor(() => { + expect(screen.getByText('fast')).toBeInTheDocument(); + }); + + await user.click(screen.getByRole('button', { name: 'Create tier' })); + + await waitFor(() => { + expect(screen.getByText('navigated-to-create')).toBeInTheDocument(); + }); + }); + + it('keeps the Create tier action available when the tier list fails to load', async () => { + renderWithProviders(, { + apiFixtures: { storageBackends: defaultBackends }, + transportOverrides: { + onStorageTierList: () => { + throw new ConnectError('tier service unavailable', Code.Unavailable); + }, + }, + }); + + await waitFor(() => { + expect(screen.getByRole('button', { name: 'Create tier' })).toBeInTheDocument(); + }); + }); + + it('navigates to the edit route when a row Edit action is clicked', async () => { + const { user } = renderWithProviders( + + } /> + navigated-to-edit} + /> + , + { + apiFixtures: { storageTiers: defaultTiers, storageBackends: defaultBackends }, + routerEntries: ['/admin/infrastructure/storage/tiers'], + }, + ); + + await waitFor(() => { + expect(screen.getByText('fast')).toBeInTheDocument(); + }); + + await user.click(screen.getByRole('button', { name: 'Actions for fast' })); + await user.click(screen.getByRole('menuitem', { name: 'Edit' })); + + await waitFor(() => { + expect(screen.getByText('navigated-to-edit')).toBeInTheDocument(); + }); + }); + + it('removes the row from the table when delete succeeds', async () => { + let tiers = [...defaultTiers]; + const { user } = renderWithProviders(, { + apiFixtures: { storageBackends: defaultBackends }, + transportOverrides: { + onStorageTierList: () => ({ items: tiers, size: tiers.length, total: tiers.length }), + onStorageTierDelete: (req) => { + tiers = tiers.filter((tier) => tier.id !== req.id); + return {}; + }, + }, + }); + + await waitFor(() => { + expect(screen.getByText('fast')).toBeInTheDocument(); + }); + + await user.click(screen.getByRole('button', { name: 'Actions for fast' })); + await user.click(screen.getByRole('menuitem', { name: 'Delete' })); + await user.click(screen.getByRole('button', { name: /^Delete$/i })); + + await waitFor(() => { + expect(screen.queryByText('fast')).not.toBeInTheDocument(); + }); + expect(screen.getByText('mixed')).toBeInTheDocument(); + }); +}); diff --git a/libs/ui-components/src/pages/admin/StorageTiersListPage.tsx b/libs/ui-components/src/pages/admin/StorageTiersListPage.tsx new file mode 100644 index 00000000..2c3efd42 --- /dev/null +++ b/libs/ui-components/src/pages/admin/StorageTiersListPage.tsx @@ -0,0 +1,138 @@ +import { useMemo } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { Alert, Button, Flex, FlexItem, Stack, StackItem } from '@patternfly/react-core'; +import { Table, Tbody, Td, Th, Thead, Tr } from '@patternfly/react-table'; +import type { TFunction } from 'i18next'; + +import type { StorageTier } from '@osac/types/private'; +import { StorageProtocol } from '@osac/types/private'; + +import { + storageBackendIdsFilter, + usePrivateStorageBackends, +} from '../../api/v1/private/storage-backends'; +import { usePrivateStorageTiers } from '../../api/v1/private/storage-tiers'; +import ListPageBody from '../../components/Page/ListPageBody'; +import StorageTierActionsMenu from '../../components/Storage/StorageTierActionsMenu'; +import { StorageTierStatusLabel } from '../../components/Storage/StorageTierStatusLabel'; +import { SubtleContent } from '../../components/SubtleContent/SubtleContent'; +import { useTranslation } from '../../hooks/useTranslation'; + +const protocolLabel = (t: TFunction, protocol: StorageProtocol): string => { + switch (protocol) { + case StorageProtocol.NFS: + return t('NFS'); + case StorageProtocol.BLOCK: + return t('Block'); + default: + return '—'; + } +}; + +const uniqueBackendIds = (tiers: StorageTier[]): string[] => { + const ids = new Set(); + tiers.forEach((tier) => { + tier.spec?.backends.forEach((backend) => ids.add(backend.backendId)); + }); + return Array.from(ids).sort(); +}; + +export const StorageTiersListPage = () => { + const { t } = useTranslation(); + const navigate = useNavigate(); + + const { data: tiers = [], isLoading, error } = usePrivateStorageTiers(); + + const backendIds = useMemo(() => uniqueBackendIds(tiers), [tiers]); + + const { data: backends = [], error: backendsError } = usePrivateStorageBackends( + { filter: storageBackendIdsFilter(backendIds) }, + { enabled: backendIds.length > 0 }, + ); + + const backendsById = useMemo( + () => new Map(backends.map((backend) => [backend.id, backend])), + [backends], + ); + + return ( + + + + + + + + + + + + {Boolean(backendsError) && ( + + + {t( + 'Backend IDs are shown in place of names until this recovers. This is separate from the normal fallback shown when a tier references a backend that no longer exists.', + )} + + + )} + + {tiers.length === 0 ? ( + + {t('No storage tiers yet. Create one to get started.')} + + ) : ( + + + + + + + + + + + {tiers.map((tier) => { + const backendAssociations = tier.spec?.backends ?? []; + return ( + + + + + + + + ); + })} + +
{t('Name')}{t('Status')}{t('Backends')}{t('Protocol(s)')} +
{tier.metadata?.name || tier.id} + + + {backendAssociations + .map( + (association) => + backendsById.get(association.backendId)?.metadata?.name ?? + association.backendId, + ) + .join(', ')} + + {backendAssociations + .map((association) => protocolLabel(t, association.protocol)) + .join(', ')} + + +
+ )} +
+
+
+
+
+ ); +}; diff --git a/libs/ui-components/src/test-utils/createMockConnectTransport.ts b/libs/ui-components/src/test-utils/createMockConnectTransport.ts index 916059c5..0aa90374 100644 --- a/libs/ui-components/src/test-utils/createMockConnectTransport.ts +++ b/libs/ui-components/src/test-utils/createMockConnectTransport.ts @@ -1,3 +1,4 @@ +import type { MessageInitShape } from '@bufbuild/protobuf'; import { Code, ConnectError, type Transport, createRouterTransport } from '@connectrpc/connect'; import type { @@ -37,13 +38,14 @@ import type { StorageBackend, StorageBackendsCreateRequest, StorageBackendsCreateResponse, + StorageBackendsListRequest, StorageBackendsUpdateRequest, StorageBackendsUpdateResponse, StorageTier, StorageTiersCreateRequest, StorageTiersCreateResponse, + StorageTiersDeleteRequest, StorageTiersListRequest, - StorageTiersListResponse, StorageTiersUpdateRequest, StorageTiersUpdateResponse, TenantsCreateRequest, @@ -54,8 +56,11 @@ import { Tenants as PrivateTenants, StorageBackendState, StorageBackends, + StorageBackendsListResponseSchema, StorageTierState, StorageTiers, + StorageTiersDeleteResponseSchema, + StorageTiersListResponseSchema, } from '@osac/types/private'; import { UnauthorizedError } from '../utils/unauthorizedError'; @@ -149,11 +154,19 @@ export type MockTransportOverrides = { req: IdentityProvidersUpdateRequest, ) => IdentityProvidersUpdateResponse; onTenantCreate?: (req: TenantsCreateRequest) => TenantsCreateResponse; + onStorageBackendList?: ( + req: StorageBackendsListRequest, + ) => MessageInitShape; onStorageBackendCreate?: (req: StorageBackendsCreateRequest) => StorageBackendsCreateResponse; onStorageBackendUpdate?: (req: StorageBackendsUpdateRequest) => StorageBackendsUpdateResponse; - onStorageTierList?: (req: StorageTiersListRequest) => StorageTiersListResponse; + onStorageTierList?: ( + req: StorageTiersListRequest, + ) => MessageInitShape; onStorageTierCreate?: (req: StorageTiersCreateRequest) => StorageTiersCreateResponse; onStorageTierUpdate?: (req: StorageTiersUpdateRequest) => StorageTiersUpdateResponse; + onStorageTierDelete?: ( + req: StorageTiersDeleteRequest, + ) => MessageInitShape; }; export const createMockConnectTransport = ( @@ -296,6 +309,9 @@ export const createMockConnectTransport = ( router.service(StorageBackends, { list: (req) => { + if (overrides.onStorageBackendList) { + return overrides.onStorageBackendList(req); + } const items = storageBackends.filter((item) => matchesStorageBackendReadyFilter(req.filter, item.status?.state), ); @@ -359,7 +375,12 @@ export const createMockConnectTransport = ( } return { object: req.object }; }, - delete: () => ({}), + delete: (req) => { + if (overrides.onStorageTierDelete) { + return overrides.onStorageTierDelete(req); + } + return {}; + }, }); router.service(PrivateInstanceTypes, {