From 546e10af664e87334f6a991c4016634ba11663aa Mon Sep 17 00:00:00 2001 From: Alex Freska Date: Fri, 22 Nov 2024 16:58:48 -0500 Subject: [PATCH] feat(renterd): hosts multi-select and batch manage blocklist and allowlist --- .changeset/metal-numbers-impress.md | 5 + .changeset/pink-buses-hear.md | 5 + apps/renterd-e2e/src/fixtures/contracts.ts | 17 +-- apps/renterd-e2e/src/fixtures/hosts.ts | 8 ++ apps/renterd-e2e/src/specs/contracts.spec.ts | 10 +- apps/renterd-e2e/src/specs/hosts.spec.ts | 105 +++++++++++++++++- .../ContractsAddAllowlist.tsx | 45 +------- .../ContractsAddBlocklist.tsx | 48 +------- .../ContractsRemoveAllowlist.tsx | 43 +------ .../ContractsRemoveBlocklist.tsx | 50 +-------- .../components/Hosts/HostContextMenu.tsx | 9 +- .../HostsBatchMenu/HostsAddAllowlist.tsx | 17 +++ .../HostsBatchMenu/HostsAddBlocklist.tsx | 18 +++ .../HostsBatchMenu/HostsRemoveAllowlist.tsx | 19 ++++ .../HostsBatchMenu/HostsRemoveBlocklist.tsx | 22 ++++ .../HostsResetLostSectorCount.tsx | 56 ++++++++++ .../components/Hosts/HostsBatchMenu/index.tsx | 25 +++++ apps/renterd/components/Hosts/Layout.tsx | 2 + apps/renterd/components/Hosts/index.tsx | 2 +- .../bulkActions/BulkAddAllowlist.tsx | 52 +++++++++ .../bulkActions/BulkAddBlocklist.tsx | 56 ++++++++++ .../bulkActions/BulkRemoveAllowlist.tsx | 52 +++++++++ .../bulkActions/BulkRemoveBlocklist.tsx | 56 ++++++++++ apps/renterd/contexts/hosts/columns.tsx | 15 ++- apps/renterd/contexts/hosts/index.tsx | 43 +++++-- apps/renterd/contexts/hosts/types.tsx | 7 +- libs/renterd-types/src/bus.ts | 2 +- 27 files changed, 586 insertions(+), 203 deletions(-) create mode 100644 .changeset/metal-numbers-impress.md create mode 100644 .changeset/pink-buses-hear.md create mode 100644 apps/renterd/components/Hosts/HostsBatchMenu/HostsAddAllowlist.tsx create mode 100644 apps/renterd/components/Hosts/HostsBatchMenu/HostsAddBlocklist.tsx create mode 100644 apps/renterd/components/Hosts/HostsBatchMenu/HostsRemoveAllowlist.tsx create mode 100644 apps/renterd/components/Hosts/HostsBatchMenu/HostsRemoveBlocklist.tsx create mode 100644 apps/renterd/components/Hosts/HostsBatchMenu/HostsResetLostSectorCount.tsx create mode 100644 apps/renterd/components/Hosts/HostsBatchMenu/index.tsx create mode 100644 apps/renterd/components/bulkActions/BulkAddAllowlist.tsx create mode 100644 apps/renterd/components/bulkActions/BulkAddBlocklist.tsx create mode 100644 apps/renterd/components/bulkActions/BulkRemoveAllowlist.tsx create mode 100644 apps/renterd/components/bulkActions/BulkRemoveBlocklist.tsx diff --git a/.changeset/metal-numbers-impress.md b/.changeset/metal-numbers-impress.md new file mode 100644 index 000000000..48dc291b8 --- /dev/null +++ b/.changeset/metal-numbers-impress.md @@ -0,0 +1,5 @@ +--- +'renterd': minor +--- + +The hosts multi-select menu now supports bulk adding and removing to both the allowlist and blocklists. diff --git a/.changeset/pink-buses-hear.md b/.changeset/pink-buses-hear.md new file mode 100644 index 000000000..b145c111b --- /dev/null +++ b/.changeset/pink-buses-hear.md @@ -0,0 +1,5 @@ +--- +'renterd': minor +--- + +- The hosts table now supports multi-select. diff --git a/apps/renterd-e2e/src/fixtures/contracts.ts b/apps/renterd-e2e/src/fixtures/contracts.ts index ba2f158df..cbe844d2c 100644 --- a/apps/renterd-e2e/src/fixtures/contracts.ts +++ b/apps/renterd-e2e/src/fixtures/contracts.ts @@ -33,10 +33,13 @@ export const getContractRowByIndex = step( } ) -export const getContractRows = step('get contract rows', async (page: Page) => { - return page - .getByTestId('contractsTable') - .locator('tbody') - .getByRole('row') - .all() -}) +export function getContractRows(page: Page) { + return page.getByTestId('contractsTable').locator('tbody').getByRole('row') +} + +export const getContractRowsAll = step( + 'get contract rows', + async (page: Page) => { + return getContractRows(page).all() + } +) diff --git a/apps/renterd-e2e/src/fixtures/hosts.ts b/apps/renterd-e2e/src/fixtures/hosts.ts index 03c52f9cb..b6043ed7c 100644 --- a/apps/renterd-e2e/src/fixtures/hosts.ts +++ b/apps/renterd-e2e/src/fixtures/hosts.ts @@ -63,6 +63,14 @@ export const openRowHostContextMenu = step( } ) +export function getHostRows(page: Page) { + return page.getByTestId('hostsTable').locator('tbody').getByRole('row') +} + +export const getHostRowsAll = step('get host rows', async (page: Page) => { + return getHostRows(page).all() +}) + export const openManageListsDialog = step( 'open manage lists dialog', async (page: Page) => { diff --git a/apps/renterd-e2e/src/specs/contracts.spec.ts b/apps/renterd-e2e/src/specs/contracts.spec.ts index 407f8ec70..061b7b1ea 100644 --- a/apps/renterd-e2e/src/specs/contracts.spec.ts +++ b/apps/renterd-e2e/src/specs/contracts.spec.ts @@ -4,7 +4,7 @@ import { navigateToContracts } from '../fixtures/navigate' import { afterTest, beforeTest } from '../fixtures/beforeTest' import { getContractRowByIndex, - getContractRows, + getContractRowsAll, getContractsSummaryRow, } from '../fixtures/contracts' import { openManageListsDialog } from '../fixtures/hosts' @@ -56,7 +56,7 @@ test('contracts prunable size', async ({ page }) => { await expect(summarySize).toBeVisible() // Check that the prunable size is visible for all contracts. - const rows = await getContractRows(page) + const rows = await getContractRowsAll(page) for (const row of rows) { const prunableSize = row.getByLabel('prunable size') await expect(prunableSize).toBeVisible() @@ -65,7 +65,7 @@ test('contracts prunable size', async ({ page }) => { test('contracts bulk delete', async ({ page }) => { await navigateToContracts({ page }) - const rows = await getContractRows(page) + const rows = await getContractRowsAll(page) for (const row of rows) { await row.click() } @@ -83,7 +83,7 @@ test('contracts bulk delete', async ({ page }) => { test('contracts bulk allowlist', async ({ page }) => { await navigateToContracts({ page }) - const rows = await getContractRows(page) + const rows = await getContractRowsAll(page) for (const row of rows) { await row.click() } @@ -119,7 +119,7 @@ test('contracts bulk allowlist', async ({ page }) => { test('contracts bulk blocklist', async ({ page }) => { await navigateToContracts({ page }) - const rows = await getContractRows(page) + const rows = await getContractRowsAll(page) for (const row of rows) { await row.click() } diff --git a/apps/renterd-e2e/src/specs/hosts.spec.ts b/apps/renterd-e2e/src/specs/hosts.spec.ts index 3aeb0df88..865eadaa2 100644 --- a/apps/renterd-e2e/src/specs/hosts.spec.ts +++ b/apps/renterd-e2e/src/specs/hosts.spec.ts @@ -1,7 +1,12 @@ import { test, expect } from '@playwright/test' import { navigateToHosts } from '../fixtures/navigate' import { afterTest, beforeTest } from '../fixtures/beforeTest' -import { getHostRowByIndex } from '../fixtures/hosts' +import { + getHostRowByIndex, + getHostRows, + getHostRowsAll, + openManageListsDialog, +} from '../fixtures/hosts' test.beforeEach(async ({ page }) => { await beforeTest(page, { @@ -23,3 +28,101 @@ test('hosts explorer shows all hosts', async ({ page }) => { await expect(row2).toBeVisible() await expect(row3).toBeVisible() }) + +test('hosts bulk allowlist', async ({ page }) => { + await navigateToHosts({ page }) + const rows = await getHostRowsAll(page) + for (const row of rows) { + await row.click() + } + + const menu = page.getByLabel('host multi-select menu') + const dialog = page.getByRole('dialog') + + // Add selected hosts to the allowlist. + await menu.getByLabel('add host public keys to allowlist').click() + await dialog.getByRole('button', { name: 'Add to allowlist' }).click() + + await openManageListsDialog(page) + await expect(dialog.getByText('The blocklist is empty')).toBeVisible() + await dialog.getByLabel('view allowlist').click() + await expect( + dialog.getByTestId('allowlistPublicKeys').getByTestId('item') + ).toHaveCount(3) + await dialog.getByLabel('close').click() + await expect( + getHostRows(page).getByTestId('allow').getByTestId('blocked') + ).toHaveCount(0) + await expect( + getHostRows(page).getByTestId('allow').getByTestId('allowed') + ).toHaveCount(3) + + for (const row of rows) { + await row.click() + } + + // Remove selected hosts from the allowlist. + await menu.getByLabel('remove host public keys from allowlist').click() + await dialog.getByRole('button', { name: 'Remove from allowlist' }).click() + + await openManageListsDialog(page) + await expect(dialog.getByText('The blocklist is empty')).toBeVisible() + await dialog.getByLabel('view allowlist').click() + await expect(dialog.getByText('The allowlist is empty')).toBeVisible() + await dialog.getByLabel('close').click() + await expect( + getHostRows(page).getByTestId('allow').getByTestId('blocked') + ).toHaveCount(0) + await expect( + getHostRows(page).getByTestId('allow').getByTestId('allowed') + ).toHaveCount(3) +}) + +test('hosts bulk blocklist', async ({ page }) => { + await navigateToHosts({ page }) + const rows = await getHostRowsAll(page) + for (const row of rows) { + await row.click() + } + + const menu = page.getByLabel('host multi-select menu') + const dialog = page.getByRole('dialog') + + // Add selected hosts to the allowlist. + await menu.getByLabel('add host addresses to blocklist').click() + await dialog.getByRole('button', { name: 'Add to blocklist' }).click() + + await openManageListsDialog(page) + await expect( + dialog.getByTestId('blocklistAddresses').getByTestId('item') + ).toHaveCount(3) + await dialog.getByLabel('view allowlist').click() + await expect(dialog.getByText('The allowlist is empty')).toBeVisible() + await dialog.getByLabel('close').click() + await expect( + getHostRows(page).getByTestId('allow').getByTestId('blocked') + ).toHaveCount(3) + await expect( + getHostRows(page).getByTestId('allow').getByTestId('allowed') + ).toHaveCount(0) + + for (const row of rows) { + await row.click() + } + + // Remove selected hosts from the blocklist. + await menu.getByLabel('remove host addresses from blocklist').click() + await dialog.getByRole('button', { name: 'Remove from blocklist' }).click() + + await openManageListsDialog(page) + await expect(dialog.getByText('The blocklist is empty')).toBeVisible() + await dialog.getByLabel('view allowlist').click() + await expect(dialog.getByText('The allowlist is empty')).toBeVisible() + await dialog.getByLabel('close').click() + await expect( + getHostRows(page).getByTestId('allow').getByTestId('blocked') + ).toHaveCount(0) + await expect( + getHostRows(page).getByTestId('allow').getByTestId('allowed') + ).toHaveCount(3) +}) diff --git a/apps/renterd/components/Contracts/ContractsBatchMenu/ContractsAddAllowlist.tsx b/apps/renterd/components/Contracts/ContractsBatchMenu/ContractsAddAllowlist.tsx index c84c51bae..8cbb081d0 100644 --- a/apps/renterd/components/Contracts/ContractsBatchMenu/ContractsAddAllowlist.tsx +++ b/apps/renterd/components/Contracts/ContractsBatchMenu/ContractsAddAllowlist.tsx @@ -1,10 +1,6 @@ -import { Button, Paragraph } from '@siafoundation/design-system' -import { ListChecked16 } from '@siafoundation/react-icons' -import { useCallback, useMemo } from 'react' -import { useDialog } from '../../../contexts/dialog' +import { useMemo } from 'react' import { useContracts } from '../../../contexts/contracts' -import { pluralize } from '@siafoundation/units' -import { useAllowlistUpdate } from '../../../hooks/useAllowlistUpdate' +import { BulkAddAllowlist } from '../../bulkActions/BulkAddAllowlist' export function ContractsAddAllowlist() { const { multiSelect } = useContracts() @@ -14,41 +10,6 @@ export function ContractsAddAllowlist() { Object.entries(multiSelect.selectionMap).map(([_, item]) => item.hostKey), [multiSelect.selectionMap] ) - const { openConfirmDialog } = useDialog() - const allowlistUpdate = useAllowlistUpdate() - const add = useCallback(async () => { - allowlistUpdate(publicKeys, []) - multiSelect.deselectAll() - }, [allowlistUpdate, multiSelect, publicKeys]) - - return ( - - ) + return } diff --git a/apps/renterd/components/Contracts/ContractsBatchMenu/ContractsAddBlocklist.tsx b/apps/renterd/components/Contracts/ContractsBatchMenu/ContractsAddBlocklist.tsx index 416370e31..ce930c9ac 100644 --- a/apps/renterd/components/Contracts/ContractsBatchMenu/ContractsAddBlocklist.tsx +++ b/apps/renterd/components/Contracts/ContractsBatchMenu/ContractsAddBlocklist.tsx @@ -1,10 +1,6 @@ -import { Button, Paragraph } from '@siafoundation/design-system' -import { ListChecked16 } from '@siafoundation/react-icons' -import { useCallback, useMemo } from 'react' -import { useDialog } from '../../../contexts/dialog' +import { useMemo } from 'react' import { useContracts } from '../../../contexts/contracts' -import { pluralize } from '@siafoundation/units' -import { useBlocklistUpdate } from '../../../hooks/useBlocklistUpdate' +import { BulkAddBlocklist } from '../../bulkActions/BulkAddBlocklist' export function ContractsAddBlocklist() { const { multiSelect } = useContracts() @@ -14,45 +10,7 @@ export function ContractsAddBlocklist() { Object.entries(multiSelect.selectionMap).map(([_, item]) => item.hostIp), [multiSelect.selectionMap] ) - const { openConfirmDialog } = useDialog() - const blocklistUpdate = useBlocklistUpdate() - - const add = useCallback(async () => { - blocklistUpdate(hostAddresses, []) - multiSelect.deselectAll() - }, [blocklistUpdate, multiSelect, hostAddresses]) - return ( - + ) } diff --git a/apps/renterd/components/Contracts/ContractsBatchMenu/ContractsRemoveAllowlist.tsx b/apps/renterd/components/Contracts/ContractsBatchMenu/ContractsRemoveAllowlist.tsx index cb62e38d6..9da6e927e 100644 --- a/apps/renterd/components/Contracts/ContractsBatchMenu/ContractsRemoveAllowlist.tsx +++ b/apps/renterd/components/Contracts/ContractsBatchMenu/ContractsRemoveAllowlist.tsx @@ -1,10 +1,6 @@ -import { Button, Paragraph } from '@siafoundation/design-system' -import { ListChecked16 } from '@siafoundation/react-icons' -import { useCallback, useMemo } from 'react' -import { useDialog } from '../../../contexts/dialog' +import { useMemo } from 'react' import { useContracts } from '../../../contexts/contracts' -import { pluralize } from '@siafoundation/units' -import { useAllowlistUpdate } from '../../../hooks/useAllowlistUpdate' +import { BulkRemoveAllowlist } from '../../bulkActions/BulkRemoveAllowlist' export function ContractsRemoveAllowlist() { const { multiSelect } = useContracts() @@ -14,41 +10,8 @@ export function ContractsRemoveAllowlist() { Object.entries(multiSelect.selectionMap).map(([_, item]) => item.hostKey), [multiSelect.selectionMap] ) - const { openConfirmDialog } = useDialog() - const allowlistUpdate = useAllowlistUpdate() - - const remove = useCallback(async () => { - await allowlistUpdate([], publicKeys) - multiSelect.deselectAll() - }, [allowlistUpdate, multiSelect, publicKeys]) return ( - + ) } diff --git a/apps/renterd/components/Contracts/ContractsBatchMenu/ContractsRemoveBlocklist.tsx b/apps/renterd/components/Contracts/ContractsBatchMenu/ContractsRemoveBlocklist.tsx index 702f5acb6..84bd74b87 100644 --- a/apps/renterd/components/Contracts/ContractsBatchMenu/ContractsRemoveBlocklist.tsx +++ b/apps/renterd/components/Contracts/ContractsBatchMenu/ContractsRemoveBlocklist.tsx @@ -1,10 +1,6 @@ -import { Button, Paragraph } from '@siafoundation/design-system' -import { ListChecked16 } from '@siafoundation/react-icons' -import { useCallback, useMemo } from 'react' -import { useDialog } from '../../../contexts/dialog' +import { useMemo } from 'react' import { useContracts } from '../../../contexts/contracts' -import { pluralize } from '@siafoundation/units' -import { useBlocklistUpdate } from '../../../hooks/useBlocklistUpdate' +import { BulkRemoveBlocklist } from '../../bulkActions/BulkRemoveBlocklist' export function ContractsRemoveBlocklist() { const { multiSelect } = useContracts() @@ -14,45 +10,11 @@ export function ContractsRemoveBlocklist() { Object.entries(multiSelect.selectionMap).map(([_, item]) => item.hostIp), [multiSelect.selectionMap] ) - const { openConfirmDialog } = useDialog() - const blocklistUpdate = useBlocklistUpdate() - - const remove = useCallback(async () => { - blocklistUpdate([], hostAddresses) - multiSelect.deselectAll() - }, [blocklistUpdate, multiSelect, hostAddresses]) return ( - + ) } diff --git a/apps/renterd/components/Hosts/HostContextMenu.tsx b/apps/renterd/components/Hosts/HostContextMenu.tsx index 59ff3bc10..8d8d91be1 100644 --- a/apps/renterd/components/Hosts/HostContextMenu.tsx +++ b/apps/renterd/components/Hosts/HostContextMenu.tsx @@ -52,7 +52,12 @@ export function HostContextMenu({ + ) @@ -223,7 +228,7 @@ export function HostContextMenuContent({ onSelect={() => resetLostSectors.post({ params: { - publicKey, + publickey: publicKey, }, }) } diff --git a/apps/renterd/components/Hosts/HostsBatchMenu/HostsAddAllowlist.tsx b/apps/renterd/components/Hosts/HostsBatchMenu/HostsAddAllowlist.tsx new file mode 100644 index 000000000..3fbe9f637 --- /dev/null +++ b/apps/renterd/components/Hosts/HostsBatchMenu/HostsAddAllowlist.tsx @@ -0,0 +1,17 @@ +import { useMemo } from 'react' +import { useHosts } from '../../../contexts/hosts' +import { BulkAddAllowlist } from '../../bulkActions/BulkAddAllowlist' + +export function HostsAddAllowlist() { + const { multiSelect } = useHosts() + + const publicKeys = useMemo( + () => + Object.entries(multiSelect.selectionMap).map( + ([_, item]) => item.publicKey + ), + [multiSelect.selectionMap] + ) + + return +} diff --git a/apps/renterd/components/Hosts/HostsBatchMenu/HostsAddBlocklist.tsx b/apps/renterd/components/Hosts/HostsBatchMenu/HostsAddBlocklist.tsx new file mode 100644 index 000000000..2b0d74d4e --- /dev/null +++ b/apps/renterd/components/Hosts/HostsBatchMenu/HostsAddBlocklist.tsx @@ -0,0 +1,18 @@ +import { useMemo } from 'react' +import { useHosts } from '../../../contexts/hosts' +import { BulkAddBlocklist } from '../../bulkActions/BulkAddBlocklist' + +export function HostsAddBlocklist() { + const { multiSelect } = useHosts() + + const hostAddresses = useMemo( + () => + Object.entries(multiSelect.selectionMap).map( + ([_, item]) => item.netAddress + ), + [multiSelect.selectionMap] + ) + return ( + + ) +} diff --git a/apps/renterd/components/Hosts/HostsBatchMenu/HostsRemoveAllowlist.tsx b/apps/renterd/components/Hosts/HostsBatchMenu/HostsRemoveAllowlist.tsx new file mode 100644 index 000000000..bde238dd2 --- /dev/null +++ b/apps/renterd/components/Hosts/HostsBatchMenu/HostsRemoveAllowlist.tsx @@ -0,0 +1,19 @@ +import { useMemo } from 'react' +import { useHosts } from '../../../contexts/hosts' +import { BulkRemoveAllowlist } from '../../bulkActions/BulkRemoveAllowlist' + +export function HostsRemoveAllowlist() { + const { multiSelect } = useHosts() + + const publicKeys = useMemo( + () => + Object.entries(multiSelect.selectionMap).map( + ([_, item]) => item.publicKey + ), + [multiSelect.selectionMap] + ) + + return ( + + ) +} diff --git a/apps/renterd/components/Hosts/HostsBatchMenu/HostsRemoveBlocklist.tsx b/apps/renterd/components/Hosts/HostsBatchMenu/HostsRemoveBlocklist.tsx new file mode 100644 index 000000000..e1543136e --- /dev/null +++ b/apps/renterd/components/Hosts/HostsBatchMenu/HostsRemoveBlocklist.tsx @@ -0,0 +1,22 @@ +import { useMemo } from 'react' +import { useHosts } from '../../../contexts/hosts' +import { BulkRemoveBlocklist } from '../../bulkActions/BulkRemoveBlocklist' + +export function HostsRemoveBlocklist() { + const { multiSelect } = useHosts() + + const hostAddresses = useMemo( + () => + Object.entries(multiSelect.selectionMap).map( + ([_, item]) => item.netAddress + ), + [multiSelect.selectionMap] + ) + + return ( + + ) +} diff --git a/apps/renterd/components/Hosts/HostsBatchMenu/HostsResetLostSectorCount.tsx b/apps/renterd/components/Hosts/HostsBatchMenu/HostsResetLostSectorCount.tsx new file mode 100644 index 000000000..1073c1a41 --- /dev/null +++ b/apps/renterd/components/Hosts/HostsBatchMenu/HostsResetLostSectorCount.tsx @@ -0,0 +1,56 @@ +import { Button } from '@siafoundation/design-system' +import { ResetAlt16 } from '@siafoundation/react-icons' +import { useHostResetLostSectorCount } from '@siafoundation/renterd-react' +import { useCallback, useMemo } from 'react' +import { handleBatchOperation } from '../../../lib/handleBatchOperation' +import { pluralize } from '@siafoundation/units' +import { useHosts } from '../../../contexts/hosts' + +export function HostsResetLostSectorCount() { + const resetLostSectors = useHostResetLostSectorCount() + const { multiSelect } = useHosts() + + const publicKeys = useMemo( + () => + Object.entries(multiSelect.selectionMap).map( + ([_, item]) => item.publicKey + ), + [multiSelect.selectionMap] + ) + const resetAll = useCallback(async () => { + await handleBatchOperation( + publicKeys.map((publicKey) => + resetLostSectors.post({ + params: { + publickey: publicKey, + }, + }) + ), + { + toastError: ({ successCount, errorCount, totalCount }) => ({ + title: `Reset lost sector count for ${pluralize( + successCount, + 'host' + )}`, + body: `Error reseting lost sector count for ${errorCount}/${totalCount} total hosts.`, + }), + toastSuccess: ({ totalCount }) => ({ + title: `Reset lost sector count for ${pluralize(totalCount, 'host')}`, + }), + after: () => { + multiSelect.deselectAll() + }, + } + ) + }, [multiSelect, publicKeys, resetLostSectors]) + + return ( + + ) +} diff --git a/apps/renterd/components/Hosts/HostsBatchMenu/index.tsx b/apps/renterd/components/Hosts/HostsBatchMenu/index.tsx new file mode 100644 index 000000000..6138e5ec7 --- /dev/null +++ b/apps/renterd/components/Hosts/HostsBatchMenu/index.tsx @@ -0,0 +1,25 @@ +import { MultiSelectionMenu } from '@siafoundation/design-system' +import { HostsResetLostSectorCount } from './HostsResetLostSectorCount' +import { HostsAddBlocklist } from './HostsAddBlocklist' +import { HostsAddAllowlist } from './HostsAddAllowlist' +import { HostsRemoveBlocklist } from './HostsRemoveBlocklist' +import { HostsRemoveAllowlist } from './HostsRemoveAllowlist' +import { useHosts } from '../../../contexts/hosts' + +export function HostsBatchMenu() { + const { multiSelect } = useHosts() + + return ( + +
+ + +
+
+ + +
+ +
+ ) +} diff --git a/apps/renterd/components/Hosts/Layout.tsx b/apps/renterd/components/Hosts/Layout.tsx index b0531fffe..40a46926d 100644 --- a/apps/renterd/components/Hosts/Layout.tsx +++ b/apps/renterd/components/Hosts/Layout.tsx @@ -7,6 +7,7 @@ import { } from '../RenterdAuthedLayout' import { HostsActionsMenu } from './HostsActionsMenu' import { HostsFilterBar } from './HostsFilterBar' +import { HostsBatchMenu } from './HostsBatchMenu' export const Layout = RenterdAuthedLayout export function useLayoutProps(): RenterdAuthedPageLayoutProps { @@ -20,5 +21,6 @@ export function useLayoutProps(): RenterdAuthedPageLayoutProps { actions: , stats: , scroll: false, + dockedControls: , } } diff --git a/apps/renterd/components/Hosts/index.tsx b/apps/renterd/components/Hosts/index.tsx index 58aac72cb..a10f42f20 100644 --- a/apps/renterd/components/Hosts/index.tsx +++ b/apps/renterd/components/Hosts/index.tsx @@ -7,7 +7,7 @@ import { getHostStatus } from '../../contexts/hosts/status' export function Hosts() { const { - dataset, + datasetPage: dataset, activeHost, columns, limit, diff --git a/apps/renterd/components/bulkActions/BulkAddAllowlist.tsx b/apps/renterd/components/bulkActions/BulkAddAllowlist.tsx new file mode 100644 index 000000000..a18ff412a --- /dev/null +++ b/apps/renterd/components/bulkActions/BulkAddAllowlist.tsx @@ -0,0 +1,52 @@ +import { Button, MultiSelect, Paragraph } from '@siafoundation/design-system' +import { ListChecked16 } from '@siafoundation/react-icons' +import { useCallback } from 'react' +import { useDialog } from '../../contexts/dialog' +import { pluralize } from '@siafoundation/units' +import { useAllowlistUpdate } from '../../hooks/useAllowlistUpdate' + +export function BulkAddAllowlist({ + multiSelect, + publicKeys, +}: { + multiSelect: MultiSelect<{ id: string }> + publicKeys: string[] +}) { + const { openConfirmDialog } = useDialog() + const allowlistUpdate = useAllowlistUpdate() + + const add = useCallback(async () => { + allowlistUpdate(publicKeys, []) + multiSelect.deselectAll() + }, [allowlistUpdate, multiSelect, publicKeys]) + + return ( + + ) +} diff --git a/apps/renterd/components/bulkActions/BulkAddBlocklist.tsx b/apps/renterd/components/bulkActions/BulkAddBlocklist.tsx new file mode 100644 index 000000000..0af762459 --- /dev/null +++ b/apps/renterd/components/bulkActions/BulkAddBlocklist.tsx @@ -0,0 +1,56 @@ +import { Button, MultiSelect, Paragraph } from '@siafoundation/design-system' +import { ListChecked16 } from '@siafoundation/react-icons' +import { useCallback } from 'react' +import { useDialog } from '../../contexts/dialog' +import { pluralize } from '@siafoundation/units' +import { useBlocklistUpdate } from '../../hooks/useBlocklistUpdate' + +export function BulkAddBlocklist({ + multiSelect, + hostAddresses, +}: { + multiSelect: MultiSelect<{ id: string }> + hostAddresses: string[] +}) { + const { openConfirmDialog } = useDialog() + const blocklistUpdate = useBlocklistUpdate() + + const add = useCallback(async () => { + blocklistUpdate(hostAddresses, []) + multiSelect.deselectAll() + }, [blocklistUpdate, multiSelect, hostAddresses]) + + return ( + + ) +} diff --git a/apps/renterd/components/bulkActions/BulkRemoveAllowlist.tsx b/apps/renterd/components/bulkActions/BulkRemoveAllowlist.tsx new file mode 100644 index 000000000..b0a494612 --- /dev/null +++ b/apps/renterd/components/bulkActions/BulkRemoveAllowlist.tsx @@ -0,0 +1,52 @@ +import { Button, MultiSelect, Paragraph } from '@siafoundation/design-system' +import { ListChecked16 } from '@siafoundation/react-icons' +import { useCallback } from 'react' +import { useDialog } from '../../contexts/dialog' +import { pluralize } from '@siafoundation/units' +import { useAllowlistUpdate } from '../../hooks/useAllowlistUpdate' + +export function BulkRemoveAllowlist({ + multiSelect, + publicKeys, +}: { + multiSelect: MultiSelect<{ id: string }> + publicKeys: string[] +}) { + const { openConfirmDialog } = useDialog() + const allowlistUpdate = useAllowlistUpdate() + + const remove = useCallback(async () => { + await allowlistUpdate([], publicKeys) + multiSelect.deselectAll() + }, [allowlistUpdate, multiSelect, publicKeys]) + + return ( + + ) +} diff --git a/apps/renterd/components/bulkActions/BulkRemoveBlocklist.tsx b/apps/renterd/components/bulkActions/BulkRemoveBlocklist.tsx new file mode 100644 index 000000000..a1f512862 --- /dev/null +++ b/apps/renterd/components/bulkActions/BulkRemoveBlocklist.tsx @@ -0,0 +1,56 @@ +import { Button, MultiSelect, Paragraph } from '@siafoundation/design-system' +import { ListChecked16 } from '@siafoundation/react-icons' +import { useCallback } from 'react' +import { useDialog } from '../../contexts/dialog' +import { pluralize } from '@siafoundation/units' +import { useBlocklistUpdate } from '../../hooks/useBlocklistUpdate' + +export function BulkRemoveBlocklist({ + multiSelect, + hostAddresses, +}: { + multiSelect: MultiSelect<{ id: string }> + hostAddresses: string[] +}) { + const { openConfirmDialog } = useDialog() + const blocklistUpdate = useBlocklistUpdate() + + const remove = useCallback(async () => { + blocklistUpdate([], hostAddresses) + multiSelect.deselectAll() + }, [blocklistUpdate, multiSelect, hostAddresses]) + + return ( + + ) +} diff --git a/apps/renterd/contexts/hosts/columns.tsx b/apps/renterd/contexts/hosts/columns.tsx index 9301886c7..eee798de7 100644 --- a/apps/renterd/contexts/hosts/columns.tsx +++ b/apps/renterd/contexts/hosts/columns.tsx @@ -6,6 +6,7 @@ import { Tooltip, LoadingDots, ValueScFiat, + Checkbox, } from '@siafoundation/design-system' import { WarningSquareFilled16, @@ -42,7 +43,14 @@ export const columns: HostsTableColumn[] = ( label: '', fixed: true, category: 'general', - cellClassName: 'w-[50px] !pl-2 !pr-4 [&+*]:!pl-0', + contentClassName: '!pl-3 !pr-4', + cellClassName: 'w-[20px] !pl-0 !pr-0', + heading: ({ context: { multiSelect } }) => ( + + ), render: ({ data }) => ( ), @@ -79,7 +87,10 @@ export const columns: HostsTableColumn[] = ( }` } > -
+
{data.isBlocked ? ( diff --git a/apps/renterd/contexts/hosts/index.tsx b/apps/renterd/contexts/hosts/index.tsx index 841152e50..801cb4850 100644 --- a/apps/renterd/contexts/hosts/index.tsx +++ b/apps/renterd/contexts/hosts/index.tsx @@ -2,6 +2,7 @@ import { triggerErrorToast, truncate, useDatasetEmptyState, + useMultiSelect, useServerFilters, useTableState, } from '@siafoundation/design-system' @@ -213,16 +214,6 @@ function useHostsMain() { const error = response.error const dataState = useDatasetEmptyState(dataset, isValidating, error, filters) - const siascanUrl = useSiascanUrl() - const isAutopilotConfigured = !!autopilotState.data?.configured - const tableContext: HostContext = useMemo( - () => ({ - isAutopilotConfigured, - siascanUrl, - }), - [isAutopilotConfigured, siascanUrl] - ) - const hostsWithLocation = useMemo( () => dataset?.filter((h) => h.location) as HostDataWithLocation[], [dataset] @@ -233,6 +224,33 @@ function useHostsMain() { [dataset, activeHostPublicKey] ) + const multiSelect = useMultiSelect(dataset) + + const datasetPage = useMemo(() => { + if (!dataset) { + return undefined + } + return dataset.map((datum) => { + return { + ...datum, + onClick: (e: React.MouseEvent) => + multiSelect.onSelect(datum.id, e), + isSelected: !!multiSelect.selectionMap[datum.id], + } + }) + }, [dataset, multiSelect]) + + const siascanUrl = useSiascanUrl() + const isAutopilotConfigured = !!autopilotState.data?.configured + const tableContext: HostContext = useMemo( + () => ({ + isAutopilotConfigured, + siascanUrl, + multiSelect, + }), + [isAutopilotConfigured, siascanUrl, multiSelect] + ) + return { setCmd, viewMode, @@ -245,9 +263,9 @@ function useHostsMain() { dataState, offset, limit, - pageCount: dataset?.length || 0, + pageCount: datasetPage?.length || 0, columns: filteredTableColumns, - dataset, + datasetPage, tableContext, configurableColumns, enabledColumns, @@ -265,6 +283,7 @@ function useHostsMain() { removeFilter, removeLastFilter, resetFilters, + multiSelect, } } diff --git a/apps/renterd/contexts/hosts/types.tsx b/apps/renterd/contexts/hosts/types.tsx index 4606035e7..477aa1274 100644 --- a/apps/renterd/contexts/hosts/types.tsx +++ b/apps/renterd/contexts/hosts/types.tsx @@ -1,8 +1,13 @@ import { HostPriceTable, HostSettings } from '@siafoundation/types' import BigNumber from 'bignumber.js' import { ContractData } from '../contracts/types' +import { MultiSelect } from '@siafoundation/design-system' -export type HostContext = { isAutopilotConfigured: boolean; siascanUrl: string } +export type HostContext = { + isAutopilotConfigured: boolean + siascanUrl: string + multiSelect: MultiSelect +} export type HostData = { id: string diff --git a/libs/renterd-types/src/bus.ts b/libs/renterd-types/src/bus.ts index d888f5b88..56cec2622 100644 --- a/libs/renterd-types/src/bus.ts +++ b/libs/renterd-types/src/bus.ts @@ -271,7 +271,7 @@ export type HostsBlocklistUpdatePayload = { export type HostsBlocklistUpdateResponse = void export type HostResetLostSectorCountParams = { - publicKey: string + publickey: string } export type HostResetLostSectorCountPayload = void export type HostResetLostSectorCountResponse = void