Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(renterd): keys batch operations #783

Merged
merged 1 commit into from
Oct 17, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/calm-flies-punch.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@siafoundation/e2e': minor
---

Add getTextInputValueByName method.
5 changes: 5 additions & 0 deletions .changeset/happy-comics-retire.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'renterd': minor
---

The key management table now supports multiselect and batch deletion.
6 changes: 6 additions & 0 deletions .changeset/selfish-parrots-applaud.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'hostd': minor
'renterd': minor
---

The onboarding wizard is now bottom right aligned.
5 changes: 5 additions & 0 deletions .changeset/strong-cherries-change.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@siafoundation/e2e': minor
---

Add toggleColumnVisibility view preferences method.
4 changes: 2 additions & 2 deletions apps/hostd/components/OnboardingBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ export function OnboardingBar() {

if (maximized) {
return (
<div className="z-20 fixed bottom-5 left-1/2 -translate-x-1/2 flex justify-center">
<div className="z-20 fixed bottom-5 right-5 flex justify-center">
<Panel className="w-[400px] flex flex-col max-h-[600px]">
<ScrollArea>
<div className="flex justify-between items-center px-3 py-2 border-b border-gray-200 dark:border-graydark-300">
Expand Down Expand Up @@ -232,7 +232,7 @@ export function OnboardingBar() {
)
}
return (
<div className="z-30 fixed bottom-5 left-1/2 -translate-x-1/2 flex justify-center">
<div className="z-30 fixed bottom-5 right-5 flex justify-center">
<Button
onClick={() => setMaximized(true)}
size="large"
Expand Down
33 changes: 4 additions & 29 deletions apps/renterd-e2e/src/fixtures/contracts.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,18 @@
import { Page, expect } from '@playwright/test'
import { Page } from '@playwright/test'

export async function getContractRowById(page: Page, id: string) {
export function getContractRowById(page: Page, id: string) {
return page.getByTestId('contractsTable').getByTestId(id)
}

export async function getContractsSummaryRow(page: Page) {
export function getContractsSummaryRow(page: Page) {
return page
.getByTestId('contractsTable')
.locator('thead')
.getByRole('row')
.nth(1)
}

export async function getContractRowByIndex(page: Page, index: number) {
export function getContractRowByIndex(page: Page, index: number) {
return page
.getByTestId('contractsTable')
.locator('tbody')
Expand All @@ -27,28 +27,3 @@ export async function getContractRows(page: Page) {
.getByRole('row')
.all()
}

export async function toggleColumnVisibility(
page: Page,
name: string,
visible: boolean
) {
await page.getByLabel('configure view').click()
const configureView = page.getByRole('dialog')
const columnToggle = configureView.getByRole('checkbox', {
name,
})

if (visible) {
await expect(columnToggle).toBeVisible()
if (!(await columnToggle.isChecked())) {
await columnToggle.click()
}
} else {
await expect(columnToggle).toBeHidden()
if (await columnToggle.isChecked()) {
await columnToggle.click()
}
}
await page.getByLabel('configure view').click()
}
31 changes: 3 additions & 28 deletions apps/renterd-e2e/src/fixtures/hosts.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { Locator, Page, expect } from '@playwright/test'
import { Locator, Page } from '@playwright/test'

export async function getHostRowById(page: Page, id: string) {
export function getHostRowById(page: Page, id: string) {
return page.getByTestId('hostsTable').getByTestId(id)
}

export async function getHostsSummaryRow(page: Page) {
export function getHostsSummaryRow(page: Page) {
return page.getByTestId('hostsTable').locator('thead').getByRole('row').nth(1)
}

Expand Down Expand Up @@ -33,28 +33,3 @@ export async function openHostContextMenu(page: Page, name: string) {
export async function openRowHostContextMenu(row: Locator) {
await row.getByTestId('actions').getByRole('button').first().click()
}

export async function toggleColumnVisibility(
page: Page,
name: string,
visible: boolean
) {
await page.getByLabel('configure view').click()
const configureView = page.getByRole('dialog')
const columnToggle = configureView.getByRole('checkbox', {
name,
})

if (visible) {
await expect(columnToggle).toBeVisible()
if (!(await columnToggle.isChecked())) {
await columnToggle.click()
}
} else {
await expect(columnToggle).toBeHidden()
if (await columnToggle.isChecked()) {
await columnToggle.click()
}
}
await page.getByLabel('configure view').click()
}
41 changes: 41 additions & 0 deletions apps/renterd-e2e/src/fixtures/keys.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { Page, expect } from '@playwright/test'
import { navigateToKeys } from './navigate'
import { getTextInputValueByName } from '@siafoundation/e2e'

export function getKeyRowById(page: Page, id: string) {
return page.getByTestId('keysTable').getByTestId(id)
}

export function getKeysSummaryRow(page: Page) {
return page.getByTestId('keysTable').locator('thead').getByRole('row').nth(1)
}

export function getKeyRowByIndex(page: Page, index: number) {
return page
.getByTestId('keysTable')
.locator('tbody')
.getByRole('row')
.nth(index)
}

export async function getKeyRows(page: Page) {
return page.getByTestId('keysTable').locator('tbody').getByRole('row').all()
}

export async function createKey(page: Page): Promise<string> {
await navigateToKeys({ page })
await page.getByTestId('navbar').getByText('Create keypair').click()
const dialog = page.getByRole('dialog')
const accessKeyId = await getTextInputValueByName(page, 'name')
await dialog.getByRole('button', { name: 'Create' }).click()
const row = getKeyRowById(page, accessKeyId)
await expect(dialog).toBeHidden()
await expect(row).toBeVisible()
return accessKeyId
}

export async function openKeyContextMenu(page: Page, key: string) {
const selector = page.getByTestId(key).getByLabel('key context menu')
await expect(selector).toBeVisible()
await selector.click()
}
8 changes: 8 additions & 0 deletions apps/renterd-e2e/src/fixtures/navigate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,11 @@ export async function navigateToHosts({ page }: { page: Page }) {
await page.getByTestId('sidenav').getByLabel('Hosts').click()
await expect(page.getByTestId('navbar').getByText('Hosts')).toBeVisible()
}

export async function navigateToKeys({ page }: { page: Page }) {
await page
.getByTestId('sidenav')
.getByLabel('S3 authentication keypairs')
.click()
await expect(page.getByTestId('navbar').getByText('Keys')).toBeVisible()
}
2 changes: 1 addition & 1 deletion apps/renterd-e2e/src/specs/contracts.spec.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import { test, expect } from '@playwright/test'
import { toggleColumnVisibility } from '@siafoundation/e2e'
import { navigateToContracts } from '../fixtures/navigate'
import { afterTest, beforeTest } from '../fixtures/beforeTest'
import {
getContractRowByIndex,
getContractRows,
getContractsSummaryRow,
toggleColumnVisibility,
} from '../fixtures/contracts'

test.beforeEach(async ({ page }) => {
Expand Down
56 changes: 56 additions & 0 deletions apps/renterd-e2e/src/specs/keys.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { test, expect } from '@playwright/test'
import { afterTest, beforeTest } from '../fixtures/beforeTest'
import {
createKey,
getKeyRowById,
getKeyRowByIndex,
openKeyContextMenu,
} from '../fixtures/keys'

test.beforeEach(async ({ page }) => {
await beforeTest(page)
})

test.afterEach(async () => {
await afterTest()
})

test('create and delete a key', async ({ page }) => {
const key = await createKey(page)
const row = getKeyRowById(page, key)
await openKeyContextMenu(page, key)
await page.getByRole('menu').getByText('Delete').click()
const dialog = page.getByRole('dialog')
await dialog.getByRole('button', { name: 'Delete' }).click()
await expect(row).toBeHidden()
})

test('batch delete multiple keys', async ({ page }) => {
// Create 3 keys. Note: 1 already exists.
const key1 = await createKey(page)
const key2 = await createKey(page)
const key3 = await createKey(page)
const row1 = getKeyRowById(page, key1)
const row2 = getKeyRowById(page, key2)
const row3 = getKeyRowById(page, key3)

// There are 4 keys total. Get the first and last row.
const rowIdx0 = getKeyRowByIndex(page, 0)
const rowIdx3 = getKeyRowByIndex(page, 3)

// Select all 4 keys.
await rowIdx0.getByLabel('select key').click()
await rowIdx3.getByLabel('select key').click({ modifiers: ['Shift'] })

// Delete all 4 keys.
const menu = page.getByLabel('key multiselect menu')
await menu.getByLabel('delete selected keys').click()
const dialog = page.getByRole('dialog')
await dialog.getByRole('button', { name: 'Delete' }).click()
await expect(row1).toBeHidden()
await expect(row2).toBeHidden()
await expect(row3).toBeHidden()
await expect(
page.getByText('There are no S3 authentication keypairs yet.')
).toBeVisible()
})
13 changes: 9 additions & 4 deletions apps/renterd/components/Keys/KeyContextMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,14 +47,19 @@ export function KeyContextMenu({ s3Key, contentProps, buttonProps }: Props) {
if (response.error) {
triggerErrorToast({ title: 'Error deleting key', body: response.error })
} else {
triggerSuccessToast({ title: `Key ${s3Key} removed` })
triggerSuccessToast({ title: `Key ${s3Key} deleted` })
}
}, [settingsS3.data, s3Key, settingsS3Update])

return (
<DropdownMenu
trigger={
<Button variant="ghost" icon="hover" {...buttonProps}>
<Button
aria-label="key context menu"
icon="hover"
size="none"
{...buttonProps}
>
<CaretDown16 />
</Button>
}
Expand All @@ -76,12 +81,12 @@ export function KeyContextMenu({ s3Key, contentProps, buttonProps }: Props) {
onSelect={() => {
openConfirmDialog({
title: `Delete key ${truncate(s3Key, 15)}`,
action: 'Remove',
action: 'Delete',
variant: 'red',
body: (
<div className="flex flex-col gap-1">
<Paragraph size="14">
Are you sure you would like to remove the following key?
Are you sure you would like to delete the following key?
</Paragraph>
<Paragraph size="14" font="mono">
{truncate(s3Key, 80)}
Expand Down
80 changes: 80 additions & 0 deletions apps/renterd/components/Keys/KeysBatchMenu/KeysBatchDelete.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import {
Button,
Paragraph,
triggerSuccessToast,
triggerErrorToast,
} from '@siafoundation/design-system'
import { Delete16 } from '@siafoundation/react-icons'
import {
useSettingsS3,
useSettingsS3Update,
} from '@siafoundation/renterd-react'
import { useCallback, useMemo } from 'react'
import { omit } from '@technically/lodash'
import { useDialog } from '../../../contexts/dialog'
import { useKeys } from '../../../contexts/keys'

export function KeysBatchDelete() {
const { selectionMap, deselect } = useKeys()

const ids = useMemo(
() => Object.entries(selectionMap).map(([_, item]) => item.id),
[selectionMap]
)
const keys = useMemo(
() => Object.entries(selectionMap).map(([_, item]) => item.key),
[selectionMap]
)
const { openConfirmDialog } = useDialog()
const settingsS3 = useSettingsS3()
const settingsS3Update = useSettingsS3Update()
const deleteKeys = useCallback(async () => {
if (!settingsS3.data) {
triggerErrorToast({ title: 'Error deleting key' })
return
}
const newKeys = omit(settingsS3.data?.authentication.v4Keypairs, keys)
const response = await settingsS3Update.put({
payload: {
...settingsS3.data,
authentication: {
...settingsS3.data.authentication,
v4Keypairs: newKeys,
},
},
})
deselect(ids)
if (response.error) {
triggerErrorToast({ title: 'Error deleting keys', body: response.error })
} else {
triggerSuccessToast({ title: `Keys deleted` })
}
}, [settingsS3.data, settingsS3Update, deselect, keys, ids])

return (
<Button
aria-label="delete selected keys"
tip="Delete selected keys"
onClick={() => {
openConfirmDialog({
title: `Delete keys`,
action: 'Delete',
variant: 'red',
body: (
<div className="flex flex-col gap-1">
<Paragraph size="14">
Are you sure you would like to delete the{' '}
{ids.length.toLocaleString()} selected keys?
</Paragraph>
</div>
),
onConfirm: async () => {
deleteKeys()
},
})
}}
>
<Delete16 />
</Button>
)
}
Loading
Loading