Skip to content

Commit ad9a477

Browse files
committed
feat(hostd): upgraded alerts feature
1 parent dc08559 commit ad9a477

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

41 files changed

+1327
-668
lines changed

.changeset/forty-gifts-divide.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'hostd': minor
3+
---
4+
5+
Alerts can now be accessed via the cmd+k menu.

.changeset/four-avocados-develop.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@siafoundation/design-system': minor
3+
---
4+
5+
Removed AlertsDialog.

.changeset/short-mayflies-exercise.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@siafoundation/hostd-types': minor
3+
---
4+
5+
Added named AlertData type.

.changeset/smart-turkeys-smoke.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@siafoundation/e2e': minor
3+
---
4+
5+
Added continueToClickUntil.

.changeset/spicy-tips-boil.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'hostd': minor
3+
---
4+
5+
The hostd alerts feature is now a full page and matches the user experience of renterd alerts.

apps/hostd-e2e/src/fixtures/alerts.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { Page } from '@playwright/test'
2+
import { maybeExpectAndReturn, step } from '@siafoundation/e2e'
3+
4+
export const getAlertRows = (page: Page) => {
5+
return page.getByTestId('alertsTable').locator('tbody').getByRole('row')
6+
}
7+
8+
export const getAlertRowsAll = step('get alert rows', async (page: Page) => {
9+
return getAlertRows(page).all()
10+
})
11+
12+
export const getAlertRowByIndex = step(
13+
'get alert row by index',
14+
async (page: Page, index: number, shouldExpect?: boolean) => {
15+
return maybeExpectAndReturn(
16+
page
17+
.getByTestId('alertsTable')
18+
.locator('tbody')
19+
.getByRole('row')
20+
.nth(index),
21+
shouldExpect
22+
)
23+
}
24+
)

apps/hostd-e2e/src/fixtures/navigate.ts

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -45,12 +45,10 @@ export const navigateToContracts = step(
4545
}
4646
)
4747

48-
export const openAlertsDialog = step(
49-
'open alerts dialog',
48+
export const navigateToAlerts = step(
49+
'navigate to alerts',
5050
async (page: Page) => {
5151
await page.getByTestId('sidenav').getByLabel('Alerts').click()
52-
const dialog = page.getByRole('dialog')
53-
await expect(dialog.getByText('Alerts')).toBeVisible()
54-
return dialog
52+
await expect(page.getByTestId('navbar').getByText('Alerts')).toBeVisible()
5553
}
5654
)
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import { test, expect, Page } from '@playwright/test'
2+
import { navigateToAlerts, navigateToVolumes } from '../fixtures/navigate'
3+
import { afterTest, beforeTest } from '../fixtures/beforeTest'
4+
import { Alert } from '@siafoundation/hostd-types'
5+
import { getAlertRows } from '../fixtures/alerts'
6+
import { createVolume } from '../fixtures/volumes'
7+
import fs from 'fs'
8+
import os from 'os'
9+
import { continueToClickUntil } from '@siafoundation/e2e'
10+
11+
let dirPath = '/'
12+
13+
test.beforeEach(async ({ page }) => {
14+
await beforeTest(page)
15+
// Create a temporary directory.
16+
dirPath = fs.mkdtempSync(process.env.GITHUB_WORKSPACE || os.tmpdir())
17+
})
18+
19+
test.afterEach(async () => {
20+
await afterTest()
21+
fs.rmSync(dirPath, { recursive: true })
22+
})
23+
24+
test('filtering alerts', async ({ page }) => {
25+
await mockApiAlerts(page)
26+
await page.reload()
27+
28+
await navigateToAlerts(page)
29+
30+
// Check initial number of alerts.
31+
await expect(getAlertRows(page)).toHaveCount(2)
32+
33+
// Verify alert content.
34+
await expect(page.getByText('Volume initialized')).toBeVisible()
35+
await expect(page.getByText('Volume warning')).toBeVisible()
36+
37+
// Test filtering.
38+
await page.getByRole('button', { name: 'Info' }).click()
39+
await expect(getAlertRows(page)).toHaveCount(1)
40+
})
41+
42+
test('dismissing alerts', async ({ page }) => {
43+
const name = 'my-new-volume'
44+
await navigateToVolumes({ page })
45+
await createVolume(page, name, dirPath)
46+
await navigateToAlerts(page)
47+
await expect(getAlertRows(page).getByText('Volume initialized')).toBeVisible()
48+
// Dismissing the alert too early will cause the alert to reappear
49+
// so maybe try to dismiss more than once.
50+
await continueToClickUntil(
51+
page.getByRole('button', { name: 'dismiss alert' }),
52+
page.getByText('There are currently no alerts.')
53+
)
54+
})
55+
56+
async function mockApiAlerts(page: Page) {
57+
const alerts: Alert[] = [
58+
{
59+
id: 'c39d09ee61a5d1dd9ad97015a0e87e9286f765bbf109cafad936d5a1aa843e54',
60+
severity: 'info',
61+
message: 'Volume initialized',
62+
data: {
63+
elapsed: 95823333,
64+
target: 2623,
65+
volumeID: 3,
66+
},
67+
timestamp: '2025-01-10T10:17:52.365323-05:00',
68+
},
69+
{
70+
id: '93683b58d12c2de737a8849561b9f0dae07120eee3185f40da489d48585b416a',
71+
severity: 'warning',
72+
message: 'Volume warning',
73+
data: {
74+
elapsed: 257749500,
75+
targetSectors: 7868,
76+
volumeID: 2,
77+
},
78+
timestamp: '2025-01-10T10:17:18.754568-05:00',
79+
},
80+
]
81+
await page.route('**/api/alerts', async (route) => {
82+
await route.fulfill({ json: alerts })
83+
})
84+
}

apps/hostd-e2e/src/specs/volumes.spec.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { expect, test } from '@playwright/test'
2-
import { navigateToVolumes, openAlertsDialog } from '../fixtures/navigate'
2+
import { navigateToVolumes, navigateToAlerts } from '../fixtures/navigate'
33
import {
44
createVolume,
55
deleteVolume,
@@ -13,6 +13,7 @@ import fs from 'fs'
1313
import os from 'os'
1414
import { fillTextInputByName } from '@siafoundation/e2e'
1515
import path from 'path'
16+
import { getAlertRows } from '../fixtures/alerts'
1617

1718
let dirPath = '/'
1819

@@ -31,9 +32,9 @@ test('can create and delete a volume', async ({ page }) => {
3132
const name = 'my-new-volume'
3233
await navigateToVolumes({ page })
3334
await createVolume(page, name, dirPath)
34-
const dialog = await openAlertsDialog(page)
35-
await expect(dialog.getByText('Volume initialized')).toBeVisible()
36-
await dialog.getByLabel('close').click()
35+
await navigateToAlerts(page)
36+
await expect(getAlertRows(page).getByText('Volume initialized')).toBeVisible()
37+
await navigateToVolumes({ page })
3738
await deleteVolume(page, name, dirPath)
3839
})
3940

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import {
2+
DropdownMenu,
3+
DropdownMenuItem,
4+
Button,
5+
DropdownMenuLeftSlot,
6+
DropdownMenuLabel,
7+
Text,
8+
} from '@siafoundation/design-system'
9+
import { CaretDown16, Checkmark16 } from '@siafoundation/react-icons'
10+
import { useAlerts } from '../../contexts/alerts'
11+
12+
type Props = {
13+
id: string
14+
contentProps?: React.ComponentProps<typeof DropdownMenu>['contentProps']
15+
buttonProps?: React.ComponentProps<typeof Button>
16+
}
17+
18+
export function AlertContextMenu({ id, contentProps, buttonProps }: Props) {
19+
const { dismissOne } = useAlerts()
20+
21+
return (
22+
<DropdownMenu
23+
trigger={
24+
<Button variant="ghost" icon="hover" {...buttonProps}>
25+
<CaretDown16 />
26+
</Button>
27+
}
28+
contentProps={{
29+
align: 'start',
30+
...contentProps,
31+
onClick: (e) => {
32+
e.stopPropagation()
33+
},
34+
}}
35+
>
36+
<div className="px-1.5 py-1">
37+
<Text size="14" weight="medium" color="subtle">
38+
Alert {id.slice(0, 24)}...
39+
</Text>
40+
</div>
41+
<DropdownMenuLabel>Actions</DropdownMenuLabel>
42+
<DropdownMenuItem
43+
onSelect={() => {
44+
dismissOne(id)
45+
}}
46+
>
47+
<DropdownMenuLeftSlot>
48+
<Checkmark16 />
49+
</DropdownMenuLeftSlot>
50+
Clear alert
51+
</DropdownMenuItem>
52+
</DropdownMenu>
53+
)
54+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { AlertsViewDropdownMenu } from './AlertsViewDropdownMenu'
2+
3+
export function AlertsActionsMenu() {
4+
return (
5+
<div className="flex gap-2">
6+
<AlertsViewDropdownMenu />
7+
</div>
8+
)
9+
}
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import {
2+
CommandGroup,
3+
CommandItemNav,
4+
CommandItemSearch,
5+
} from '../../CmdRoot/Item'
6+
import { Page } from '../../CmdRoot/types'
7+
import { useRouter } from 'next/router'
8+
import { useDialog } from '../../../contexts/dialog'
9+
import { routes } from '../../../config/routes'
10+
11+
export const commandPage = {
12+
namespace: 'alerts',
13+
label: 'Alerts',
14+
}
15+
16+
export function AlertsCmd({
17+
currentPage,
18+
parentPage,
19+
pushPage,
20+
}: {
21+
currentPage: Page
22+
parentPage?: Page
23+
beforeSelect?: () => void
24+
afterSelect?: () => void
25+
pushPage: (page: Page) => void
26+
}) {
27+
const router = useRouter()
28+
const { closeDialog } = useDialog()
29+
return (
30+
<>
31+
<CommandItemNav
32+
currentPage={currentPage}
33+
parentPage={parentPage}
34+
commandPage={parentPage}
35+
onSelect={() => {
36+
pushPage(commandPage)
37+
}}
38+
>
39+
{commandPage.label}
40+
</CommandItemNav>
41+
<CommandGroup currentPage={currentPage} commandPage={commandPage}>
42+
<CommandItemSearch
43+
currentPage={currentPage}
44+
commandPage={commandPage}
45+
onSelect={() => {
46+
router.push(routes.alerts.index)
47+
closeDialog()
48+
}}
49+
>
50+
View alerts
51+
</CommandItemSearch>
52+
</CommandGroup>
53+
</>
54+
)
55+
}

0 commit comments

Comments
 (0)