From c3778e08c6f7b7d78502fbe9c65f29d45d759c2c 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 + .changeset/sour-eagles-tickle.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 +- .../components/Hosts/HostMap/Globe.tsx | 2 +- .../components/Hosts/HostMap/index.tsx | 11 +- .../HostsBatchMenu/HostsAddAllowlist.tsx | 17 +++ .../HostsBatchMenu/HostsAddBlocklist.tsx | 18 +++ .../HostsBatchMenu/HostsRemoveAllowlist.tsx | 19 +++ .../HostsBatchMenu/HostsRemoveBlocklist.tsx | 22 ++++ .../HostsResetLostSectorCount.tsx | 55 ++++++++ .../components/Hosts/HostsBatchMenu/index.tsx | 25 ++++ apps/renterd/components/Hosts/Layout.tsx | 2 + apps/renterd/components/Hosts/index.tsx | 8 +- .../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/dataset.ts | 9 +- apps/renterd/contexts/hosts/index.tsx | 121 ++++++++++-------- apps/renterd/contexts/hosts/types.tsx | 11 +- .../src/multi/useMultiSelect.tsx | 4 +- libs/renterd-types/src/bus.ts | 4 +- 32 files changed, 642 insertions(+), 267 deletions(-) create mode 100644 .changeset/metal-numbers-impress.md create mode 100644 .changeset/pink-buses-hear.md create mode 100644 .changeset/sour-eagles-tickle.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/.changeset/sour-eagles-tickle.md b/.changeset/sour-eagles-tickle.md new file mode 100644 index 000000000..5eeced39a --- /dev/null +++ b/.changeset/sour-eagles-tickle.md @@ -0,0 +1,5 @@ +--- +'renterd': minor +--- + +The host map must now be explicitly toggled open with the action button in the navbar. 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..0d18622ed 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, + hostkey: publicKey, }, }) } diff --git a/apps/renterd/components/Hosts/HostMap/Globe.tsx b/apps/renterd/components/Hosts/HostMap/Globe.tsx index 73d2a6151..c6e5a89f0 100644 --- a/apps/renterd/components/Hosts/HostMap/Globe.tsx +++ b/apps/renterd/components/Hosts/HostMap/Globe.tsx @@ -26,7 +26,7 @@ type Props = { activeHost?: HostDataWithLocation hosts?: HostDataWithLocation[] onHostClick: (publicKey: string, location: [number, number]) => void - onHostHover: (publicKey: string, location: [number, number]) => void + onHostHover?: (publicKey: string, location: [number, number]) => void onMount?: (cmd: Commands) => void } diff --git a/apps/renterd/components/Hosts/HostMap/index.tsx b/apps/renterd/components/Hosts/HostMap/index.tsx index 5d1d2be09..f3ee7ff1f 100644 --- a/apps/renterd/components/Hosts/HostMap/index.tsx +++ b/apps/renterd/components/Hosts/HostMap/index.tsx @@ -7,13 +7,7 @@ import { hostColors } from '../../../contexts/hosts/status' export function HostMap() { const { gpu, settings } = useAppSettings() - const { - setCmd, - activeHost, - onHostMapClick: onHostSelect, - onHostMapHover: onHostHover, - hostsWithLocation, - } = useHosts() + const { setCmd, activeHost, onHostMapClick, hostsWithLocation } = useHosts() if (settings.siaCentral && !gpu.shouldRender) { return null @@ -28,8 +22,7 @@ export function HostMap() { : undefined } hosts={hostsWithLocation} - onHostClick={onHostSelect} - onHostHover={onHostHover} + onHostClick={onHostMapClick} onMount={(cmd) => { setCmd(cmd) }} 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..978c7db6d --- /dev/null +++ b/apps/renterd/components/Hosts/HostsBatchMenu/HostsResetLostSectorCount.tsx @@ -0,0 +1,55 @@ +import { Button, handleBatchOperation } from '@siafoundation/design-system' +import { ResetAlt16 } from '@siafoundation/react-icons' +import { useHostResetLostSectorCount } from '@siafoundation/renterd-react' +import { useCallback, useMemo } from 'react' +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: { + hostkey: 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..063fd18ad 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, activeHost, columns, limit, @@ -37,8 +37,8 @@ export function Hosts() { bottom: 0, height: viewMode === 'map' - ? dataset && dataset.length - ? 400 - Math.max((2 - dataset.length) * 100, 0) + ? datasetPage && datasetPage.length + ? 400 - Math.max((2 - datasetPage.length) * 100, 0) : 400 : '100%', }} @@ -60,7 +60,7 @@ export function Hosts() { emptyState={} context={tableContext} pageSize={limit} - data={dataset} + data={datasetPage} columns={columns} rowSize="default" /> 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 800377cb5..c787808e4 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/dataset.ts b/apps/renterd/contexts/hosts/dataset.ts index 7b32d9fc2..26ca7c430 100644 --- a/apps/renterd/contexts/hosts/dataset.ts +++ b/apps/renterd/contexts/hosts/dataset.ts @@ -18,7 +18,6 @@ export function useDataset({ blocklist, isAllowlistActive, geoHosts, - onHostSelect, }: { response: ReturnType allContracts: Maybe @@ -26,9 +25,8 @@ export function useDataset({ blocklist: ReturnType isAllowlistActive: boolean geoHosts: SiaCentralHost[] - onHostSelect: (publicKey: string, location?: [number, number]) => void }) { - return useMemo(() => { + return useMemo>(() => { const allow = allowlist.data const block = blocklist.data if (!response.data || !allow || !block) { @@ -37,7 +35,6 @@ export function useDataset({ return response.data.map((host) => { const sch = geoHosts.find((gh) => gh.public_key === host.publicKey) return { - onClick: () => onHostSelect(host.publicKey, sch?.location), ...getHostFields(host, allContracts), ...getAllowedFields({ host, @@ -48,10 +45,12 @@ export function useDataset({ ...getAutopilotFields(host.checks), location: sch?.location, countryCode: sch?.country_code, + // selectable + onClick: () => null, + isSelected: false, } }) }, [ - onHostSelect, response.data, allContracts, allowlist.data, diff --git a/apps/renterd/contexts/hosts/index.tsx b/apps/renterd/contexts/hosts/index.tsx index caf63aba5..dca55b327 100644 --- a/apps/renterd/contexts/hosts/index.tsx +++ b/apps/renterd/contexts/hosts/index.tsx @@ -1,7 +1,8 @@ import { - triggerErrorToast, + triggerToast, truncate, useDatasetEmptyState, + useMultiSelect, useServerFilters, useTableState, } from '@siafoundation/design-system' @@ -119,8 +120,6 @@ function useHostsMain() { [cmdRef] ) - const [activeHostPublicKey, setActiveHostPublicKey] = useState() - const scrollToHost = useCallback((publicKey: string) => { // move table to host, select via data id data-table const rowEl = document.getElementById(publicKey) @@ -134,46 +133,6 @@ function useHostsMain() { }) }, []) - const onHostMapClick = useCallback( - (publicKey: string, location?: [number, number]) => { - if (activeHostPublicKey === publicKey) { - setActiveHostPublicKey(undefined) - return - } - setActiveHostPublicKey(publicKey) - if (location) { - cmdRef.current.moveToLocation(location) - } - scrollToHost(publicKey) - }, - [setActiveHostPublicKey, cmdRef, activeHostPublicKey, scrollToHost] - ) - - const onHostListClick = useCallback( - (publicKey: string, location?: [number, number]) => { - if (activeHostPublicKey === publicKey) { - setActiveHostPublicKey(undefined) - return - } - setActiveHostPublicKey(publicKey) - if (location) { - cmdRef.current.moveToLocation(location) - } else { - triggerErrorToast({ - title: 'Geographic location is unknown for host', - body: truncate(publicKey, 20), - }) - } - scrollToHost(publicKey) - }, - [setActiveHostPublicKey, cmdRef, activeHostPublicKey, scrollToHost] - ) - - const onHostMapHover = useCallback( - (publicKey: string, location?: [number, number]) => null, - [] - ) - const dataset = useDataset({ response, allContracts, @@ -181,7 +140,6 @@ function useHostsMain() { blocklist, isAllowlistActive, geoHosts, - onHostSelect: onHostListClick, }) const { @@ -210,29 +168,81 @@ function useHostsMain() { const error = response.error const dataState = useDatasetEmptyState(dataset, isValidating, error, filters) + const hostsWithLocation = useMemo( + () => dataset?.filter((h) => h.location) as HostDataWithLocation[], + [dataset] + ) + + const multiSelect = useMultiSelect(dataset) + + const activeHost = useMemo(() => { + if (multiSelect.selectedIds.length === 1) { + return dataset?.find((d) => d.publicKey === multiSelect.selectedIds[0]) + } + }, [dataset, multiSelect.selectedIds]) + + 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 tableContext: HostContext = useMemo( () => ({ siascanUrl, + multiSelect, }), - [siascanUrl] + [siascanUrl, multiSelect] ) - const hostsWithLocation = useMemo( - () => dataset?.filter((h) => h.location) as HostDataWithLocation[], - [dataset] + const onHostMapClick = useCallback( + (publicKey: string, location?: [number, number]) => { + if (activeHost?.publicKey !== publicKey) { + multiSelect.deselectAll() + multiSelect.onSelect(publicKey) + if (location) { + cmdRef.current.moveToLocation(location) + } + scrollToHost(publicKey) + } + }, + [activeHost, multiSelect, cmdRef, scrollToHost] ) - const activeHost = useMemo( - () => dataset?.find((d) => d.publicKey === activeHostPublicKey), - [dataset, activeHostPublicKey] - ) + useEffect(() => { + if (!activeHost) { + return + } + if (viewMode !== 'map') { + return + } + const { location } = activeHost || {} + if (location) { + cmdRef.current.moveToLocation(location) + } else { + triggerToast({ + title: `Location not available for host ${truncate( + activeHost.publicKey, + 20 + )}`, + }) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [activeHost]) return { setCmd, viewMode, activeHost, - onHostMapHover, onHostMapClick, setViewMode, hostsWithLocation, @@ -240,9 +250,9 @@ function useHostsMain() { dataState, offset, limit, - pageCount: dataset?.length || 0, + pageCount: datasetPage?.length || 0, columns: filteredTableColumns, - dataset, + datasetPage, tableContext, configurableColumns, enabledColumns, @@ -260,6 +270,7 @@ function useHostsMain() { removeFilter, removeLastFilter, resetFilters, + multiSelect, } } diff --git a/apps/renterd/contexts/hosts/types.tsx b/apps/renterd/contexts/hosts/types.tsx index bf05cc423..454b1853e 100644 --- a/apps/renterd/contexts/hosts/types.tsx +++ b/apps/renterd/contexts/hosts/types.tsx @@ -1,8 +1,12 @@ 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 = { siascanUrl: string } +export type HostContext = { + siascanUrl: string + multiSelect: MultiSelect +} export type HostData = { id: string @@ -60,8 +64,13 @@ export type HostData = { location?: [number, number] countryCode?: string + + onClick: (e: React.MouseEvent) => void + isSelected: boolean } +export type HostDataWithoutSelectable = Omit + const generalColumns = [ 'actions', 'allow', diff --git a/libs/design-system/src/multi/useMultiSelect.tsx b/libs/design-system/src/multi/useMultiSelect.tsx index e2f361ab3..0738a5be2 100644 --- a/libs/design-system/src/multi/useMultiSelect.tsx +++ b/libs/design-system/src/multi/useMultiSelect.tsx @@ -16,7 +16,7 @@ export function useMultiSelect(dataset?: Item[]) { }>() const onSelect = useCallback( - (id: string, e: MouseEvent) => { + (id: string, e?: MouseEvent) => { if (!dataset) { return } @@ -34,7 +34,7 @@ export function useMultiSelect(dataset?: Item[]) { const newSelection = { ...prevSelectionMap } setLastSelectedItem((prevSelection) => { // If shift click, select all items between current and last selection indices. - if (e.shiftKey && prevSelection) { + if (e?.shiftKey && prevSelection) { if (prevSelection.index < selected.index) { for (let i = prevSelection.index; i <= selected.index; i++) { const item = dataset[i] diff --git a/libs/renterd-types/src/bus.ts b/libs/renterd-types/src/bus.ts index 2d23450bc..169c7c4c3 100644 --- a/libs/renterd-types/src/bus.ts +++ b/libs/renterd-types/src/bus.ts @@ -48,7 +48,7 @@ export const busHostsHostKeyRoute = '/bus/hosts/:hostkey' export const busHostsBlocklistRoute = '/bus/hosts/blocklist' export const busHostsAllowlistRoute = '/bus/hosts/allowlist' export const busHostPublicKeyResetlostsectorsRoute = - '/bus/host/:publickey/resetlostsectors' + '/bus/host/:hostkey/resetlostsectors' export const busContractsRoute = '/bus/contracts' export const busContractIdAcquireRoute = '/bus/contract/:id/acquire' export const busContractIdReleaseRoute = '/bus/contract/:id/release' @@ -268,7 +268,7 @@ export type HostsBlocklistUpdatePayload = { export type HostsBlocklistUpdateResponse = void export type HostResetLostSectorCountParams = { - publicKey: string + hostkey: string } export type HostResetLostSectorCountPayload = void export type HostResetLostSectorCountResponse = void