diff --git a/.changeset/itchy-shoes-lick.md b/.changeset/itchy-shoes-lick.md
new file mode 100644
index 000000000..210b30f9a
--- /dev/null
+++ b/.changeset/itchy-shoes-lick.md
@@ -0,0 +1,5 @@
+---
+'renterd': minor
+---
+
+The host and contracts multi-select menus both now include an option to rescan the selected hosts.
diff --git a/apps/renterd-e2e/src/specs/contracts.spec.ts b/apps/renterd-e2e/src/specs/contracts.spec.ts
index 6cd8eeb9d..03e03fb7f 100644
--- a/apps/renterd-e2e/src/specs/contracts.spec.ts
+++ b/apps/renterd-e2e/src/specs/contracts.spec.ts
@@ -79,6 +79,18 @@ test('contracts bulk delete', async ({ page }) => {
await expect(page.getByText('3 contracts deleted')).toBeVisible()
})
+test('contracts bulk rescan', async ({ page }) => {
+ await navigateToContracts({ page })
+ const rows = await getContractRowsAll(page)
+ rows.at(0).click()
+ rows.at(-1).click({ modifiers: ['Shift'] })
+
+ // Rescan selected hosts.
+ const menu = page.getByLabel('contract multi-select menu')
+ await menu.getByLabel('rescan selected hosts').click()
+ await expect(page.getByText('rescanning 3 hosts')).toBeVisible()
+})
+
test('contracts bulk allowlist', async ({ page }) => {
await navigateToContracts({ page })
const rows = await getContractRowsAll(page)
diff --git a/apps/renterd-e2e/src/specs/hosts.spec.ts b/apps/renterd-e2e/src/specs/hosts.spec.ts
index 641a40d21..91b7d5ede 100644
--- a/apps/renterd-e2e/src/specs/hosts.spec.ts
+++ b/apps/renterd-e2e/src/specs/hosts.spec.ts
@@ -77,6 +77,18 @@ test('hosts bulk allowlist', async ({ page }) => {
).toHaveCount(3)
})
+test('hosts bulk rescan', async ({ page }) => {
+ await navigateToHosts({ page })
+ const rows = await getHostRowsAll(page)
+ rows.at(0).click()
+ rows.at(-1).click({ modifiers: ['Shift'], position: { x: 5, y: 5 } })
+
+ // Rescan selected hosts.
+ const menu = page.getByLabel('host multi-select menu')
+ await menu.getByLabel('rescan selected hosts').click()
+ await expect(page.getByText('rescanning 3 hosts')).toBeVisible()
+})
+
test('hosts bulk blocklist', async ({ page }) => {
await navigateToHosts({ page })
const rows = await getHostRowsAll(page)
diff --git a/apps/renterd/components/Contracts/ContractsBulkMenu/ContractsRescanHosts.tsx b/apps/renterd/components/Contracts/ContractsBulkMenu/ContractsRescanHosts.tsx
new file mode 100644
index 000000000..930359bd4
--- /dev/null
+++ b/apps/renterd/components/Contracts/ContractsBulkMenu/ContractsRescanHosts.tsx
@@ -0,0 +1,15 @@
+import { useMemo } from 'react'
+import { useContracts } from '../../../contexts/contracts'
+import { BulkRescanHosts } from '../../bulkActions/BulkRescanHosts'
+
+export function ContractsRescanHosts() {
+ const { multiSelect } = useContracts()
+
+ const publicKeys = useMemo(
+ () =>
+ Object.entries(multiSelect.selection).map(([_, item]) => item.hostKey),
+ [multiSelect.selection]
+ )
+
+ return
+}
diff --git a/apps/renterd/components/Contracts/ContractsBulkMenu/index.tsx b/apps/renterd/components/Contracts/ContractsBulkMenu/index.tsx
index 3d26a754c..b9c1e7e11 100644
--- a/apps/renterd/components/Contracts/ContractsBulkMenu/index.tsx
+++ b/apps/renterd/components/Contracts/ContractsBulkMenu/index.tsx
@@ -5,6 +5,7 @@ import { ContractsAddBlocklist } from './ContractsAddBlocklist'
import { ContractsAddAllowlist } from './ContractsAddAllowlist'
import { ContractsRemoveBlocklist } from './ContractsRemoveBlocklist'
import { ContractsRemoveAllowlist } from './ContractsRemoveAllowlist'
+import { ContractsRescanHosts } from './ContractsRescanHosts'
export function ContractsBulkMenu() {
const { multiSelect } = useContracts()
@@ -19,6 +20,7 @@ export function ContractsBulkMenu() {
+
)
diff --git a/apps/renterd/components/Hosts/HostsBulkMenu/HostsRescan.tsx b/apps/renterd/components/Hosts/HostsBulkMenu/HostsRescan.tsx
new file mode 100644
index 000000000..e366f7a8b
--- /dev/null
+++ b/apps/renterd/components/Hosts/HostsBulkMenu/HostsRescan.tsx
@@ -0,0 +1,15 @@
+import { useMemo } from 'react'
+import { BulkRescanHosts } from '../../bulkActions/BulkRescanHosts'
+import { useHosts } from '../../../contexts/hosts'
+
+export function HostsRescan() {
+ const { multiSelect } = useHosts()
+
+ const publicKeys = useMemo(
+ () =>
+ Object.entries(multiSelect.selection).map(([_, item]) => item.publicKey),
+ [multiSelect.selection]
+ )
+
+ return
+}
diff --git a/apps/renterd/components/Hosts/HostsBulkMenu/index.tsx b/apps/renterd/components/Hosts/HostsBulkMenu/index.tsx
index c020341a6..5c5f5751d 100644
--- a/apps/renterd/components/Hosts/HostsBulkMenu/index.tsx
+++ b/apps/renterd/components/Hosts/HostsBulkMenu/index.tsx
@@ -5,6 +5,7 @@ import { HostsAddAllowlist } from './HostsAddAllowlist'
import { HostsRemoveBlocklist } from './HostsRemoveBlocklist'
import { HostsRemoveAllowlist } from './HostsRemoveAllowlist'
import { useHosts } from '../../../contexts/hosts'
+import { HostsRescan } from './HostsRescan'
export function HostsBulkMenu() {
const { multiSelect } = useHosts()
@@ -19,6 +20,7 @@ export function HostsBulkMenu() {
+
)
diff --git a/apps/renterd/components/bulkActions/BulkRescanHosts.tsx b/apps/renterd/components/bulkActions/BulkRescanHosts.tsx
new file mode 100644
index 000000000..5bcf01d34
--- /dev/null
+++ b/apps/renterd/components/bulkActions/BulkRescanHosts.tsx
@@ -0,0 +1,56 @@
+import {
+ Button,
+ handleBatchOperation,
+ MultiSelect,
+ MultiSelectRow,
+} from '@siafoundation/design-system'
+import { DataView16 } from '@siafoundation/react-icons'
+import { useHostScan } from '@siafoundation/renterd-react'
+import { useCallback } from 'react'
+import { pluralize, secondsInMilliseconds } from '@siafoundation/units'
+
+export function BulkRescanHosts({
+ multiSelect,
+ publicKeys,
+}: {
+ multiSelect: MultiSelect
+ publicKeys: string[]
+}) {
+ const scan = useHostScan()
+ const scanAll = useCallback(async () => {
+ await handleBatchOperation(
+ publicKeys.map((publicKey) =>
+ scan.post({
+ params: {
+ hostkey: publicKey,
+ },
+ payload: {
+ timeout: secondsInMilliseconds(30),
+ },
+ })
+ ),
+ {
+ toastError: ({ successCount, errorCount, totalCount }) => ({
+ title: `Rescanning ${pluralize(successCount, 'host')}`,
+ body: `Error starting rescan for ${errorCount}/${totalCount} of total hosts.`,
+ }),
+ toastSuccess: ({ totalCount }) => ({
+ title: `Rescanning ${pluralize(totalCount, 'host')}`,
+ }),
+ after: () => {
+ multiSelect.deselectAll()
+ },
+ }
+ )
+ }, [multiSelect, publicKeys, scan])
+
+ return (
+
+ )
+}