diff --git a/.changeset/forty-gifts-divide.md b/.changeset/forty-gifts-divide.md new file mode 100644 index 000000000..df144c263 --- /dev/null +++ b/.changeset/forty-gifts-divide.md @@ -0,0 +1,5 @@ +--- +'hostd': minor +--- + +Alerts can now be accessed via the cmd+k menu. diff --git a/.changeset/four-avocados-develop.md b/.changeset/four-avocados-develop.md new file mode 100644 index 000000000..2312df479 --- /dev/null +++ b/.changeset/four-avocados-develop.md @@ -0,0 +1,5 @@ +--- +'@siafoundation/design-system': minor +--- + +Removed AlertsDialog. diff --git a/.changeset/short-mayflies-exercise.md b/.changeset/short-mayflies-exercise.md new file mode 100644 index 000000000..c7591cebb --- /dev/null +++ b/.changeset/short-mayflies-exercise.md @@ -0,0 +1,5 @@ +--- +'@siafoundation/hostd-types': minor +--- + +Added named AlertData type. diff --git a/.changeset/smart-turkeys-smoke.md b/.changeset/smart-turkeys-smoke.md new file mode 100644 index 000000000..c0696ca43 --- /dev/null +++ b/.changeset/smart-turkeys-smoke.md @@ -0,0 +1,5 @@ +--- +'@siafoundation/e2e': minor +--- + +Added continueToClickUntil. diff --git a/.changeset/spicy-tips-boil.md b/.changeset/spicy-tips-boil.md new file mode 100644 index 000000000..d535720a3 --- /dev/null +++ b/.changeset/spicy-tips-boil.md @@ -0,0 +1,5 @@ +--- +'hostd': minor +--- + +The hostd alerts feature is now a full page and matches the user experience of renterd alerts. diff --git a/apps/hostd-e2e/src/fixtures/alerts.ts b/apps/hostd-e2e/src/fixtures/alerts.ts new file mode 100644 index 000000000..ad9a7c7cd --- /dev/null +++ b/apps/hostd-e2e/src/fixtures/alerts.ts @@ -0,0 +1,24 @@ +import { Page } from '@playwright/test' +import { maybeExpectAndReturn, step } from '@siafoundation/e2e' + +export const getAlertRows = (page: Page) => { + return page.getByTestId('alertsTable').locator('tbody').getByRole('row') +} + +export const getAlertRowsAll = step('get alert rows', async (page: Page) => { + return getAlertRows(page).all() +}) + +export const getAlertRowByIndex = step( + 'get alert row by index', + async (page: Page, index: number, shouldExpect?: boolean) => { + return maybeExpectAndReturn( + page + .getByTestId('alertsTable') + .locator('tbody') + .getByRole('row') + .nth(index), + shouldExpect + ) + } +) diff --git a/apps/hostd-e2e/src/fixtures/navigate.ts b/apps/hostd-e2e/src/fixtures/navigate.ts index f2e7caed1..7a1b2ca69 100644 --- a/apps/hostd-e2e/src/fixtures/navigate.ts +++ b/apps/hostd-e2e/src/fixtures/navigate.ts @@ -45,12 +45,10 @@ export const navigateToContracts = step( } ) -export const openAlertsDialog = step( - 'open alerts dialog', +export const navigateToAlerts = step( + 'navigate to alerts', async (page: Page) => { await page.getByTestId('sidenav').getByLabel('Alerts').click() - const dialog = page.getByRole('dialog') - await expect(dialog.getByText('Alerts')).toBeVisible() - return dialog + await expect(page.getByTestId('navbar').getByText('Alerts')).toBeVisible() } ) diff --git a/apps/hostd-e2e/src/specs/alerts.spec.ts b/apps/hostd-e2e/src/specs/alerts.spec.ts new file mode 100644 index 000000000..0ef788a3a --- /dev/null +++ b/apps/hostd-e2e/src/specs/alerts.spec.ts @@ -0,0 +1,86 @@ +import { test, expect, Page } from '@playwright/test' +import { navigateToAlerts, navigateToVolumes } from '../fixtures/navigate' +import { afterTest, beforeTest } from '../fixtures/beforeTest' +import { Alert } from '@siafoundation/hostd-types' +import { getAlertRows } from '../fixtures/alerts' +import { createVolume, deleteVolume } from '../fixtures/volumes' +import fs from 'fs' +import os from 'os' +import { continueToClickUntil } from '@siafoundation/e2e' + +let dirPath = '/' + +test.beforeEach(async ({ page }) => { + await beforeTest(page) + // Create a temporary directory. + dirPath = fs.mkdtempSync(process.env.GITHUB_WORKSPACE || os.tmpdir()) +}) + +test.afterEach(async () => { + await afterTest() + fs.rmSync(dirPath, { recursive: true }) +}) + +test('filtering alerts', async ({ page }) => { + await mockApiAlerts(page) + await page.reload() + + await navigateToAlerts(page) + + // Check initial number of alerts. + await expect(getAlertRows(page)).toHaveCount(2) + + // Verify alert content. + await expect(page.getByText('Volume initialized')).toBeVisible() + await expect(page.getByText('Volume warning')).toBeVisible() + + // Test filtering. + await page.getByRole('button', { name: 'Info' }).click() + await expect(getAlertRows(page)).toHaveCount(1) +}) + +test('dismissing alerts', async ({ page }) => { + const name = 'my-new-volume' + await navigateToVolumes({ page }) + await createVolume(page, name, dirPath) + await navigateToAlerts(page) + await expect(getAlertRows(page).getByText('Volume initialized')).toBeVisible() + // Dismissing the alert too early will cause the alert to reappear + // so maybe try to dismiss more than once. + await continueToClickUntil( + page.getByRole('button', { name: 'dismiss alert' }), + page.getByText('There are currently no alerts.') + ) + await navigateToVolumes({ page }) + await deleteVolume(page, name, dirPath) +}) + +async function mockApiAlerts(page: Page) { + const alerts: Alert[] = [ + { + id: 'c39d09ee61a5d1dd9ad97015a0e87e9286f765bbf109cafad936d5a1aa843e54', + severity: 'info', + message: 'Volume initialized', + data: { + elapsed: 95823333, + target: 2623, + volumeID: 3, + }, + timestamp: '2025-01-10T10:17:52.365323-05:00', + }, + { + id: '93683b58d12c2de737a8849561b9f0dae07120eee3185f40da489d48585b416a', + severity: 'warning', + message: 'Volume warning', + data: { + elapsed: 257749500, + targetSectors: 7868, + volumeID: 2, + }, + timestamp: '2025-01-10T10:17:18.754568-05:00', + }, + ] + await page.route('**/api/alerts', async (route) => { + await route.fulfill({ json: alerts }) + }) +} diff --git a/apps/hostd-e2e/src/specs/volumes.spec.ts b/apps/hostd-e2e/src/specs/volumes.spec.ts index d97f277cb..637a2ed89 100644 --- a/apps/hostd-e2e/src/specs/volumes.spec.ts +++ b/apps/hostd-e2e/src/specs/volumes.spec.ts @@ -1,5 +1,5 @@ import { expect, test } from '@playwright/test' -import { navigateToVolumes, openAlertsDialog } from '../fixtures/navigate' +import { navigateToVolumes, navigateToAlerts } from '../fixtures/navigate' import { createVolume, deleteVolume, @@ -13,6 +13,7 @@ import fs from 'fs' import os from 'os' import { fillTextInputByName } from '@siafoundation/e2e' import path from 'path' +import { getAlertRows } from '../fixtures/alerts' let dirPath = '/' @@ -31,9 +32,9 @@ test('can create and delete a volume', async ({ page }) => { const name = 'my-new-volume' await navigateToVolumes({ page }) await createVolume(page, name, dirPath) - const dialog = await openAlertsDialog(page) - await expect(dialog.getByText('Volume initialized')).toBeVisible() - await dialog.getByLabel('close').click() + await navigateToAlerts(page) + await expect(getAlertRows(page).getByText('Volume initialized')).toBeVisible() + await navigateToVolumes({ page }) await deleteVolume(page, name, dirPath) }) diff --git a/apps/hostd/components/Alerts/AlertContextMenu.tsx b/apps/hostd/components/Alerts/AlertContextMenu.tsx new file mode 100644 index 000000000..8624d4412 --- /dev/null +++ b/apps/hostd/components/Alerts/AlertContextMenu.tsx @@ -0,0 +1,54 @@ +import { + DropdownMenu, + DropdownMenuItem, + Button, + DropdownMenuLeftSlot, + DropdownMenuLabel, + Text, +} from '@siafoundation/design-system' +import { CaretDown16, Checkmark16 } from '@siafoundation/react-icons' +import { useAlerts } from '../../contexts/alerts' + +type Props = { + id: string + contentProps?: React.ComponentProps['contentProps'] + buttonProps?: React.ComponentProps +} + +export function AlertContextMenu({ id, contentProps, buttonProps }: Props) { + const { dismissOne } = useAlerts() + + return ( + + + + } + contentProps={{ + align: 'start', + ...contentProps, + onClick: (e) => { + e.stopPropagation() + }, + }} + > +
+ + Alert {id.slice(0, 24)}... + +
+ Actions + { + dismissOne(id) + }} + > + + + + Clear alert + +
+ ) +} diff --git a/apps/hostd/components/Alerts/AlertsActionsMenu.tsx b/apps/hostd/components/Alerts/AlertsActionsMenu.tsx new file mode 100644 index 000000000..84f2b5acc --- /dev/null +++ b/apps/hostd/components/Alerts/AlertsActionsMenu.tsx @@ -0,0 +1,9 @@ +import { AlertsViewDropdownMenu } from './AlertsViewDropdownMenu' + +export function AlertsActionsMenu() { + return ( +
+ +
+ ) +} diff --git a/apps/hostd/components/Alerts/AlertsCmd/index.tsx b/apps/hostd/components/Alerts/AlertsCmd/index.tsx new file mode 100644 index 000000000..bfd198d76 --- /dev/null +++ b/apps/hostd/components/Alerts/AlertsCmd/index.tsx @@ -0,0 +1,55 @@ +import { + CommandGroup, + CommandItemNav, + CommandItemSearch, +} from '../../CmdRoot/Item' +import { Page } from '../../CmdRoot/types' +import { useRouter } from 'next/router' +import { useDialog } from '../../../contexts/dialog' +import { routes } from '../../../config/routes' + +export const commandPage = { + namespace: 'alerts', + label: 'Alerts', +} + +export function AlertsCmd({ + currentPage, + parentPage, + pushPage, +}: { + currentPage: Page + parentPage?: Page + beforeSelect?: () => void + afterSelect?: () => void + pushPage: (page: Page) => void +}) { + const router = useRouter() + const { closeDialog } = useDialog() + return ( + <> + { + pushPage(commandPage) + }} + > + {commandPage.label} + + + { + router.push(routes.alerts.index) + closeDialog() + }} + > + View alerts + + + + ) +} diff --git a/apps/hostd/components/Alerts/AlertsFilterMenu.tsx b/apps/hostd/components/Alerts/AlertsFilterMenu.tsx new file mode 100644 index 000000000..9db90ea2c --- /dev/null +++ b/apps/hostd/components/Alerts/AlertsFilterMenu.tsx @@ -0,0 +1,96 @@ +'use client' + +import { Button, PaginatorKnownTotal, Text } from '@siafoundation/design-system' +import { useAlerts } from '../../contexts/alerts' +import { Checkmark16 } from '@siafoundation/react-icons' +import { useCallback } from 'react' +import { AlertSeverity } from '@siafoundation/hostd-types' + +export function AlertsFilterMenu() { + const { + offset, + limit, + totals, + datasetPageTotal, + datasetState, + datasetPage, + dismissMany, + clientFilters, + } = useAlerts() + + const severityFilter = clientFilters.filters.find((f) => f.id === 'severity') + const setSeverityFilter = useCallback( + (severity: AlertSeverity) => { + if (severity === undefined) { + clientFilters.removeFilter('severity') + return + } + clientFilters.setFilter({ + id: 'severity', + value: severity, + label: severity, + fn: (a) => a.severity === severity, + }) + }, + [clientFilters] + ) + + return ( +
+ Filter +
+ + + + + +
+
+ {datasetState === 'loaded' && !!datasetPageTotal && ( + + )} + +
+ ) +} diff --git a/apps/hostd/components/Alerts/AlertsViewDropdownMenu.tsx b/apps/hostd/components/Alerts/AlertsViewDropdownMenu.tsx new file mode 100644 index 000000000..dcb34e974 --- /dev/null +++ b/apps/hostd/components/Alerts/AlertsViewDropdownMenu.tsx @@ -0,0 +1,78 @@ +import { + Button, + PoolCombo, + Label, + Popover, + MenuItemRightSlot, + BaseMenuItem, + MenuSectionLabelToggleAll, +} from '@siafoundation/design-system' +import { + CaretDown16, + SettingsAdjust16, + Reset16, +} from '@siafoundation/react-icons' +import { useAlerts } from '../../contexts/alerts' + +export function AlertsViewDropdownMenu() { + const { + configurableColumns, + toggleColumnVisibility, + resetDefaultColumnVisibility, + setColumnsVisible, + setColumnsHidden, + visibleColumnIds, + } = useAlerts() + + const generalColumns = configurableColumns + .filter((c) => c.category === 'general') + .map((column) => ({ + label: column.label, + value: column.id, + })) + return ( + + + View + + + } + contentProps={{ + align: 'end', + className: 'max-w-[300px]', + }} + > + + + + + + + c.value)} + enabled={visibleColumnIds} + setColumnsVisible={setColumnsVisible} + setColumnsHidden={setColumnsHidden} + /> + + toggleColumnVisibility(value)} + /> + + + ) +} diff --git a/apps/hostd/components/Alerts/Layout.tsx b/apps/hostd/components/Alerts/Layout.tsx new file mode 100644 index 000000000..40e05b6a7 --- /dev/null +++ b/apps/hostd/components/Alerts/Layout.tsx @@ -0,0 +1,22 @@ +import { HostdSidenav } from '../HostdSidenav' +import { routes } from '../../config/routes' +import { useDialog } from '../../contexts/dialog' +import { + HostdAuthedLayout, + HostdAuthedPageLayoutProps, +} from '../HostdAuthedLayout' +import { AlertsActionsMenu } from './AlertsActionsMenu' +import { AlertsFilterMenu } from './AlertsFilterMenu' + +export const Layout = HostdAuthedLayout +export function useLayoutProps(): HostdAuthedPageLayoutProps { + const { openDialog } = useDialog() + return { + title: 'Alerts', + routes, + sidenav: , + openSettings: () => openDialog('settings'), + actions: , + stats: , + } +} diff --git a/apps/hostd/components/Alerts/StateError.tsx b/apps/hostd/components/Alerts/StateError.tsx new file mode 100644 index 000000000..164324f3c --- /dev/null +++ b/apps/hostd/components/Alerts/StateError.tsx @@ -0,0 +1,16 @@ +import { Text } from '@siafoundation/design-system' +import { MisuseOutline32 } from '@siafoundation/react-icons' + +export function StateError() { + const message = 'Error fetching alerts.' + return ( +
+ + + + + {message} + +
+ ) +} diff --git a/apps/hostd/components/Alerts/StateNoneMatching.tsx b/apps/hostd/components/Alerts/StateNoneMatching.tsx new file mode 100644 index 000000000..c97352875 --- /dev/null +++ b/apps/hostd/components/Alerts/StateNoneMatching.tsx @@ -0,0 +1,15 @@ +import { Text } from '@siafoundation/design-system' +import { Filter32 } from '@siafoundation/react-icons' + +export function StateNoneMatching() { + return ( +
+ + + + + No alerts matching filters. + +
+ ) +} diff --git a/apps/hostd/components/Alerts/StateNoneYet.tsx b/apps/hostd/components/Alerts/StateNoneYet.tsx new file mode 100644 index 000000000..722dd66df --- /dev/null +++ b/apps/hostd/components/Alerts/StateNoneYet.tsx @@ -0,0 +1,17 @@ +import { Text } from '@siafoundation/design-system' +import { Task32 } from '@siafoundation/react-icons' + +export function StateNoneYet() { + return ( +
+ + + +
+ + There are currently no alerts. + +
+
+ ) +} diff --git a/apps/hostd/components/Alerts/index.tsx b/apps/hostd/components/Alerts/index.tsx new file mode 100644 index 000000000..ab1f2391d --- /dev/null +++ b/apps/hostd/components/Alerts/index.tsx @@ -0,0 +1,43 @@ +import { EmptyState, Table } from '@siafoundation/design-system' +import { StateNoneMatching } from './StateNoneMatching' +import { StateNoneYet } from './StateNoneYet' +import { StateError } from './StateError' +import { useAlerts } from '../../contexts/alerts' + +export function Alerts() { + const { + visibleColumns, + datasetPage, + sortField, + sortDirection, + sortableColumns, + toggleSort, + limit, + datasetState, + } = useAlerts() + + return ( +
+ } + noneYet={} + error={} + /> + } + sortableColumns={sortableColumns} + pageSize={limit} + data={datasetPage} + columns={visibleColumns} + sortDirection={sortDirection} + sortField={sortField} + toggleSort={toggleSort} + rowSize="auto" + /> + + ) +} diff --git a/apps/hostd/components/CmdRoot/index.tsx b/apps/hostd/components/CmdRoot/index.tsx index 82d0a846a..85cda7599 100644 --- a/apps/hostd/components/CmdRoot/index.tsx +++ b/apps/hostd/components/CmdRoot/index.tsx @@ -20,6 +20,7 @@ import { useDialog } from '../../contexts/dialog' import { useRouter } from 'next/router' import { routes } from '../../config/routes' import { VolumesCmd } from '../Volumes/VolumesCmd' +import { AlertsCmd } from '../Alerts/AlertsCmd' type Props = { panel?: boolean @@ -103,6 +104,20 @@ export function CmdRoot({ panel }: Props) { afterSelect() }} /> + { + beforeSelect() + resetContractsFilters() + }} + afterSelect={() => { + if (!router.pathname.startsWith(routes.alerts.index)) { + router.push(routes.alerts.index) + } + afterSelect() + }} + /> diff --git a/apps/hostd/components/Contracts/ContractContextMenu.tsx b/apps/hostd/components/Contracts/ContractContextMenu.tsx index 1340fac68..90a1ffd8d 100644 --- a/apps/hostd/components/Contracts/ContractContextMenu.tsx +++ b/apps/hostd/components/Contracts/ContractContextMenu.tsx @@ -14,8 +14,8 @@ import { import { CaretDown16, DataCheck16 } from '@siafoundation/react-icons' import { ContractStatus } from '@siafoundation/hostd-types' import { useContractsIntegrityCheck } from '@siafoundation/hostd-react' -import { useDialog } from '../../contexts/dialog' import { useCallback } from 'react' +import { routes } from '../../config/routes' type Props = { id: string @@ -31,7 +31,6 @@ export function ContractContextMenu({ buttonProps, }: Props) { const integrityCheck = useContractsIntegrityCheck() - const { openDialog } = useDialog() const runIntegrityCheck = useCallback(async () => { const response = await integrityCheck.put({ params: { @@ -50,8 +49,7 @@ export function ContractContextMenu({ <> Depending on contract data size this operation can take a while. Check hostd{' '} - openDialog('alerts')}>alerts for status - updates. + alerts for status updates. ), options: { @@ -59,7 +57,7 @@ export function ContractContextMenu({ }, }) } - }, [id, integrityCheck, openDialog]) + }, [id, integrityCheck]) const dataIntegrityCheckAvailable = ['active', 'pending'].includes(status) return ( diff --git a/apps/hostd/components/Contracts/ContractsBulkMenu/ContractsBulkIntegrityCheck.tsx b/apps/hostd/components/Contracts/ContractsBulkMenu/ContractsBulkIntegrityCheck.tsx index 17af4b826..d1b22b553 100644 --- a/apps/hostd/components/Contracts/ContractsBulkMenu/ContractsBulkIntegrityCheck.tsx +++ b/apps/hostd/components/Contracts/ContractsBulkMenu/ContractsBulkIntegrityCheck.tsx @@ -9,10 +9,9 @@ import { useCallback, useMemo } from 'react' import { pluralize } from '@siafoundation/units' import { useContracts } from '../../../contexts/contracts' import { useContractsIntegrityCheck } from '@siafoundation/hostd-react' -import { useDialog } from '../../../contexts/dialog' +import { routes } from '../../../config/routes' export function ContractsBulkIntegrityCheck() { - const { openDialog } = useDialog() const { multiSelect } = useContracts() const integrityCheck = useContractsIntegrityCheck() @@ -46,8 +45,7 @@ export function ContractsBulkIntegrityCheck() { <> Depending on contract data size this operation can take a while. Check hostd{' '} - openDialog('alerts')}>alerts for - status updates. + alerts for status updates. ), }), @@ -56,7 +54,7 @@ export function ContractsBulkIntegrityCheck() { }, } ) - }, [multiSelect, ids, integrityCheck, openDialog]) + }, [multiSelect, ids, integrityCheck]) return ( + {/* */} + + ), + }, + { + id: 'overview', + label: 'overview', + category: 'general', + contentClassName: 'min-w-[200px] max-w-[500px]', + rowCellClassName: 'align-top pt-[5px]', + render: ({ data: { message, severity, data } }) => { + return ( +
+
+ + {severity} + + + {message} + +
+ {data['hint'] ? ( + + {data['hint'] as string} + + ) : null} + {data['error'] ? ( + + ) : null} +
+ ) + }, + }, + { + id: 'data', + label: 'data', + contentClassName: 'w-[500px]', + rowCellClassName: 'align-top', + category: 'general', + render: function DataColumn({ data: { data } }) { + // Collect data for data fields + const datums = useMemo( + () => + objectEntries(dataFields) + .map(([key]) => { + const value = data[key] + if ( + value === undefined || + value === null || + (typeof value === 'object' && !Object.keys(value).length) + ) { + return false + } + return { key, value } + }) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + .filter((data) => data) as { key: string; value: any }[], + [data] + ) + return ( +
+ + {datums.map(({ key, value }, i) => { + const Component: // eslint-disable-next-line @typescript-eslint/no-explicit-any + ((props: { value: any }) => React.ReactNode) | undefined = + dataFields?.[key as keyof AlertData['data']]?.render + if (!Component) { + return null + } + return ( + +
+ +
+ {datums.length > 1 && i < datums.length - 1 && ( + + )} +
+ ) + })} +
+
+ ) + }, + }, + { + id: 'time', + label: 'time', + category: 'general', + contentClassName: 'w-[120px] justify-end', + rowCellClassName: 'align-top pt-[26px]', + render: ({ data: { timestamp } }) => { + return ( + + {formatRelative(new Date(timestamp), new Date())} + + ) + }, + }, +] diff --git a/apps/hostd/contexts/alerts/data.tsx b/apps/hostd/contexts/alerts/data.tsx new file mode 100644 index 000000000..68365fec8 --- /dev/null +++ b/apps/hostd/contexts/alerts/data.tsx @@ -0,0 +1,252 @@ +import { Text, ValueCopyable } from '@siafoundation/design-system' +import { humanTime } from '@siafoundation/units' +import { AlertData } from './types' + +export const dataFields: { + [K in keyof AlertData['data']]: { + render: (props: { value: NonNullable }) => JSX.Element + } +} = { + error: { + render({ value }) { + return ( +
+ + error + + + {value} + +
+ ) + }, + }, + contractID: { + render({ value }) { + return ( +
+ + contract ID + + +
+ ) + }, + }, + blockHeight: { + render({ value }) { + return ( +
+ + block height + + +
+ ) + }, + }, + resolution: { + render({ value }) { + return ( +
+ + resolution + + +
+ ) + }, + }, + volume: { + render({ value }) { + return ( +
+ + volume + + +
+ ) + }, + }, + volumeID: { + render({ value }) { + return ( +
+ + volume ID + + +
+ ) + }, + }, + elapsed: { + render({ value }) { + return ( +
+ + elapsed + + + {humanTime(Number(value))} + +
+ ) + }, + }, + checked: { + render({ value }) { + return ( +
+ + checked + + + {value.toLocaleString()} + +
+ ) + }, + }, + missing: { + render({ value }) { + return ( +
+ + missing + + + {value.toLocaleString()} + +
+ ) + }, + }, + corrupt: { + render({ value }) { + return ( +
+ + corrupt + + + {value.toLocaleString()} + +
+ ) + }, + }, + total: { + render({ value }) { + return ( +
+ + total + + + {value.toLocaleString()} + +
+ ) + }, + }, + oldSectors: { + render({ value }) { + return ( +
+ + old sectors + + + {value.toLocaleString()} + +
+ ) + }, + }, + currentSectors: { + render({ value }) { + return ( +
+ + current sectors + + + {value.toLocaleString()} + +
+ ) + }, + }, + targetSectors: { + render({ value }) { + return ( +
+ + target sectors + + + {value.toLocaleString()} + +
+ ) + }, + }, + migratedSectors: { + render({ value }) { + return ( +
+ + migrated sectors + + + {value.toLocaleString()} + +
+ ) + }, + }, + migrated: { + render({ value }) { + return ( +
+ + migrated + + + {value.toLocaleString()} + +
+ ) + }, + }, + target: { + render({ value }) { + return ( +
+ + target + + + {value.toLocaleString()} + +
+ ) + }, + }, + force: { + render({ value }) { + return ( +
+ + force + + + {value ? 'true' : 'false'} + +
+ ) + }, + }, +} diff --git a/apps/hostd/contexts/alerts/index.tsx b/apps/hostd/contexts/alerts/index.tsx new file mode 100644 index 000000000..c6cf168dd --- /dev/null +++ b/apps/hostd/contexts/alerts/index.tsx @@ -0,0 +1,203 @@ +import { + useTableState, + useDatasetState, + usePaginationOffset, + useClientFilters, +} from '@siafoundation/design-system' +import { createContext, useContext, useMemo } from 'react' +import { + AlertData, + columnsDefaultVisible, + defaultSortField, + sortOptions, +} from './types' +import { columns } from './columns' +import { defaultDatasetRefreshInterval } from '../../config/swr' +import { + triggerErrorToast, + triggerSuccessToast, +} from '@siafoundation/design-system' +import { + useAlerts as useAlertsData, + useAlertsDismiss, +} from '@siafoundation/hostd-react' +import { useCallback } from 'react' +import { Maybe } from '@siafoundation/types' +import { AlertSeverity } from '@siafoundation/hostd-types' + +const defaultLimit = 50 + +function useAlertsMain() { + const { limit, offset } = usePaginationOffset(defaultLimit) + + const response = useAlertsData({ + config: { + swr: { + refreshInterval: defaultDatasetRefreshInterval, + }, + }, + }) + const dismiss = useAlertsDismiss() + + const dismissOne = useCallback( + async (id: string) => { + const response = await dismiss.post({ + payload: [id], + }) + if (response.error) { + triggerErrorToast({ + title: 'Error dismissing alert', + body: response.error, + }) + } else { + triggerSuccessToast({ title: 'Alert has been dismissed' }) + } + }, + [dismiss] + ) + + const dismissMany = useCallback( + async (ids: string[]) => { + const response = await dismiss.post({ + payload: ids, + }) + if (response.error) { + triggerErrorToast({ + title: 'Error dismissing alerts', + body: response.error, + }) + } else { + triggerSuccessToast({ title: 'Selected alerts have been dismissed' }) + } + }, + [dismiss] + ) + + const dataset = useMemo>(() => { + if (!response.data) { + return undefined + } + const data: AlertData[] = + response.data?.map((a) => ({ + id: a.id, + severity: a.severity, + message: a.message, + timestamp: a.timestamp, + data: a.data, + dismiss: () => dismissOne(a.id), + })) || [] + return data + }, [response.data, dismissOne]) + + const clientFilters = useClientFilters() + const datasetFiltered = useMemo>(() => { + if (!dataset) { + return undefined + } + const severityFilter = clientFilters.filters.find( + (f) => f.id === 'severity' + ) + if (severityFilter) { + return dataset.filter(severityFilter.fn) + } + return dataset + }, [dataset, clientFilters.filters]) + + const datasetPage = useMemo>(() => { + if (!datasetFiltered) { + return undefined + } + return datasetFiltered.slice(offset, offset + limit) + }, [datasetFiltered, offset, limit]) + + const { + configurableColumns, + visibleColumns, + visibleColumnIds, + sortableColumns, + toggleColumnVisibility, + setColumnsVisible, + setColumnsHidden, + toggleSort, + setSortDirection, + setSortField, + sortField, + sortDirection, + resetDefaultColumnVisibility, + } = useTableState('hostd/v0/alerts', { + columns, + columnsDefaultVisible, + sortOptions, + defaultSortField, + }) + + const datasetState = useDatasetState({ + datasetPage, + isValidating: response.isValidating, + error: response.error, + filters: clientFilters.filters, + offset, + }) + + // Compute severity totals from complete client-side dataset. + const totals = useMemo(() => { + const totals: Record = { + all: 0, + info: 0, + warning: 0, + error: 0, + critical: 0, + } + if (!dataset) { + return totals + } + for (const a of dataset) { + totals.all += 1 + totals[a.severity] = (totals[a.severity] || 0) + 1 + } + return totals + }, [dataset]) + + return { + datasetState, + limit, + offset, + isLoading: response.isLoading, + error: response.error, + datasetPageTotal: datasetPage?.length || 0, + totals, + datasetPage, + configurableColumns, + visibleColumns, + visibleColumnIds, + sortableColumns, + toggleColumnVisibility, + setColumnsVisible, + setColumnsHidden, + toggleSort, + setSortDirection, + setSortField, + clientFilters, + sortField, + sortDirection, + resetDefaultColumnVisibility, + dismissOne, + dismissMany, + } +} + +type State = ReturnType + +const AlertsContext = createContext({} as State) +export const useAlerts = () => useContext(AlertsContext) + +type Props = { + children: React.ReactNode +} + +export function AlertsProvider({ children }: Props) { + const state = useAlertsMain() + return ( + {children} + ) +} diff --git a/apps/hostd/contexts/alerts/types.ts b/apps/hostd/contexts/alerts/types.ts new file mode 100644 index 000000000..113a1532f --- /dev/null +++ b/apps/hostd/contexts/alerts/types.ts @@ -0,0 +1,32 @@ +import { + AlertSeverity, + AlertData as AlertDataField, +} from '@siafoundation/hostd-types' + +export type AlertData = { + id: string + severity: AlertSeverity + message: string + timestamp: string + data: AlertDataField + dismiss: () => void +} + +export type TableColumnId = 'actions' | 'overview' | 'data' | 'time' + +export const columnsDefaultVisible: TableColumnId[] = [ + 'actions', + 'overview', + 'data', + 'time', +] + +export type SortField = '' + +export const defaultSortField: SortField = '' + +export const sortOptions: { + id: SortField + label: string + category: string +}[] = [] diff --git a/apps/hostd/contexts/dialog.tsx b/apps/hostd/contexts/dialog.tsx index e77a37bd6..8378aff68 100644 --- a/apps/hostd/contexts/dialog.tsx +++ b/apps/hostd/contexts/dialog.tsx @@ -13,7 +13,6 @@ import { HostdSendSiacoinDialog } from '../dialogs/HostdSendSiacoinDialog' import { HostdTransactionDetailsDialog } from '../dialogs/HostdTransactionDetailsDialog' import { ContractsFilterContractIdDialog } from '../dialogs/ContractsFilterContractIdDialog' import { CmdKDialog } from '../components/CmdKDialog' -import { AlertsDialog } from '../dialogs/AlertsDialog' export type DialogType = | 'cmdk' @@ -28,7 +27,6 @@ export type DialogType = | 'volumeDelete' | 'contractsFilterContractId' | 'confirm' - | 'alerts' type ConfirmProps = { title: React.ReactNode @@ -123,7 +121,6 @@ export function Dialogs() { open={dialog === 'settings'} onOpenChange={onOpenChange} /> - void -} - -export function AlertsDialog({ open, onOpenChange }: Props) { - const alerts = useAlerts({ - config: { - swr: { - refreshInterval: defaultDatasetRefreshInterval, - }, - }, - }) - const dismiss = useAlertsDismiss() - - const dismissOne = useCallback( - async (id: string) => { - const response = await dismiss.post({ - payload: [id], - }) - if (response.error) { - triggerErrorToast({ - title: 'Error dismissing alert', - body: response.error, - }) - } else { - triggerSuccessToast({ title: 'Alert has been dismissed' }) - } - }, - [dismiss] - ) - - const dismissMany = useCallback( - async (ids: string[], filter?: AlertSeverity) => { - if (!alerts.data) { - return - } - const response = await dismiss.post({ - payload: ids, - }) - if (response.error) { - triggerErrorToast({ - title: filter - ? `Error dismissing all ${filter} alerts` - : 'Error dismissing all alerts', - body: response.error, - }) - } else { - triggerSuccessToast({ - title: filter - ? `All ${filter} alerts have been dismissed` - : 'All alerts have been dismissed', - }) - } - }, - [dismiss, alerts] - ) - - return ( - { - onOpenChange(val) - }} - alerts={alerts} - dataFieldOrder={dataFieldOrder} - dataFields={dataFields} - dismissMany={dismissMany} - dismissOne={dismissOne} - /> - ) -} - -const dataFieldOrder = [ - 'error', - 'contractID', - 'blockHeight', - 'resolution', - 'volume', - 'volumeID', - 'elapsed', - 'error', - 'checked', - 'missing', - 'corrupt', - 'total', - 'oldSectors', - 'currentSectors', - 'targetSectors', - 'migratedSectors', - 'migrated', - 'target', - 'force', -] - -const dataFields: Record< - string, - { render: (props: { value: unknown }) => JSX.Element } -> = { - error: { - render: ({ value }: { value: string }) => ( -
- - error - - {value} -
- ), - }, - contractId: { - render: ({ value }: { value: string }) => ( -
- - contract ID - - -
- ), - }, - blockHeight: { - render: ({ value }: { value: string }) => ( -
- - block height - - -
- ), - }, - resolution: { - render: ({ value }: { value: string }) => ( -
- - resolution - - -
- ), - }, - volume: { - render: ({ value }: { value: string }) => ( -
- - volume - - -
- ), - }, - volumeID: { - render: ({ value }: { value: string }) => ( -
- - volume ID - - -
- ), - }, - elapsed: { - render: ({ value }: { value: string }) => ( -
- - elapsed - - - {humanTime(Number(value))} - -
- ), - }, - checked: { - render: ({ value }: { value: string }) => ( -
- - checked - - - {value.toLocaleString()} - -
- ), - }, - missing: { - render: ({ value }: { value: string }) => ( -
- - missing - - - {value.toLocaleString()} - -
- ), - }, - corrupt: { - render: ({ value }: { value: string }) => ( -
- - corrupt - - - {value.toLocaleString()} - -
- ), - }, - total: { - render: ({ value }: { value: string }) => ( -
- - total - - - {value.toLocaleString()} - -
- ), - }, - oldSectors: { - render: ({ value }: { value: string }) => ( -
- - old sectors - - - {value.toLocaleString()} - -
- ), - }, - currentSectors: { - render: ({ value }: { value: string }) => ( -
- - current sectors - - - {value.toLocaleString()} - -
- ), - }, - targetSectors: { - render: ({ value }: { value: string }) => ( -
- - target sectors - - - {value.toLocaleString()} - -
- ), - }, - migratedSectors: { - render: ({ value }: { value: string }) => ( -
- - migrated sectors - - - {value.toLocaleString()} - -
- ), - }, - migrated: { - render: ({ value }: { value: string }) => ( -
- - migrated - - - {value.toLocaleString()} - -
- ), - }, - target: { - render: ({ value }: { value: string }) => ( -
- - target - - - {value.toLocaleString()} - -
- ), - }, - force: { - render: ({ value }: { value: string }) => ( -
- - force - - - {value ? 'true' : 'false'} - -
- ), - }, -} diff --git a/apps/hostd/pages/alerts/index.tsx b/apps/hostd/pages/alerts/index.tsx new file mode 100644 index 000000000..14b18fd76 --- /dev/null +++ b/apps/hostd/pages/alerts/index.tsx @@ -0,0 +1,9 @@ +import { Alerts } from '../../components/Alerts' +import { Layout, useLayoutProps } from '../../components/Alerts/Layout' + +export default function Page() { + return +} + +Page.Layout = Layout +Page.useLayoutProps = useLayoutProps diff --git a/apps/renterd/components/Alerts/index.tsx b/apps/renterd/components/Alerts/index.tsx index ecaf04a8b..ab1f2391d 100644 --- a/apps/renterd/components/Alerts/index.tsx +++ b/apps/renterd/components/Alerts/index.tsx @@ -1,4 +1,4 @@ -import { Table } from '@siafoundation/design-system' +import { EmptyState, Table } from '@siafoundation/design-system' import { StateNoneMatching } from './StateNoneMatching' import { StateNoneYet } from './StateNoneYet' import { StateError } from './StateError' @@ -19,15 +19,15 @@ export function Alerts() { return (
- ) : datasetState === 'noneYet' ? ( - - ) : datasetState === 'error' ? ( - - ) : null + } + noneYet={} + error={} + /> } sortableColumns={sortableColumns} pageSize={limit} diff --git a/apps/renterd/contexts/alerts/columns.tsx b/apps/renterd/contexts/alerts/columns.tsx index 7eb85cc97..2e4cc6370 100644 --- a/apps/renterd/contexts/alerts/columns.tsx +++ b/apps/renterd/contexts/alerts/columns.tsx @@ -31,7 +31,11 @@ export const columns: AlertsTableColumn[] = [ rowCellClassName: 'align-top pt-[19px]', render: ({ data: { dismiss } }) => ( - {/* */} diff --git a/apps/renterd/contexts/alerts/index.tsx b/apps/renterd/contexts/alerts/index.tsx index c860b85cf..7830ea49b 100644 --- a/apps/renterd/contexts/alerts/index.tsx +++ b/apps/renterd/contexts/alerts/index.tsx @@ -82,7 +82,7 @@ function useAlertsMain() { body: response.error, }) } else { - triggerSuccessToast({ title: 'Alert has been dismissed.' }) + triggerSuccessToast({ title: 'Alert has been dismissed' }) } }, [dismiss] diff --git a/libs/design-system/package.json b/libs/design-system/package.json index d200d3a22..e30a2897b 100644 --- a/libs/design-system/package.json +++ b/libs/design-system/package.json @@ -13,7 +13,6 @@ "@siafoundation/types": "^0.7.0", "@siafoundation/next": "^0.1.3", "@radix-ui/react-visually-hidden": "^1.1.0", - "swr": "^2.1.1", "class-variance-authority": "^0.7.0", "bignumber.js": "^9.0.2", "axios": "^0.27.2", diff --git a/libs/design-system/src/app/AlertsDialog/StateEmpty.tsx b/libs/design-system/src/app/AlertsDialog/StateEmpty.tsx deleted file mode 100644 index 212bc1994..000000000 --- a/libs/design-system/src/app/AlertsDialog/StateEmpty.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import { Text } from '../../core/Text' -import { Filter32 } from '@siafoundation/react-icons' - -type Props = { - filtered: boolean -} - -export function StateEmpty({ filtered }: Props) { - return ( -
- - - - - {filtered ? 'No matching alerts.' : 'There are currently no alerts.'} - -
- ) -} diff --git a/libs/design-system/src/app/AlertsDialog/index.tsx b/libs/design-system/src/app/AlertsDialog/index.tsx deleted file mode 100644 index 59245d0d3..000000000 --- a/libs/design-system/src/app/AlertsDialog/index.tsx +++ /dev/null @@ -1,262 +0,0 @@ -'use client' - -import { Button } from '../../core/Button' -import { Dialog } from '../../core/Dialog' -import { Heading } from '../../core/Heading' -import { Checkmark16 } from '@siafoundation/react-icons' -import { Skeleton } from '../../core/Skeleton' -import { Text } from '../../core/Text' -import { useDatasetState } from '../../hooks/useDatasetState' -import { humanDate } from '@siafoundation/units' -import { cx } from 'class-variance-authority' -import { times } from '@technically/lodash' -import { useCallback, useMemo, useState } from 'react' -import { StateEmpty } from './StateEmpty' -import { SWRResponse } from 'swr' - -type AlertSeverity = 'info' | 'warning' | 'error' | 'critical' -type Alert = { - id: string - severity: AlertSeverity - message: string - timestamp: string - data: Record -} - -type Props = { - open: boolean - onOpenChange: (val: boolean) => void - alerts: SWRResponse - dismissOne: (id: string) => void - dismissMany: (ids: string[], filter?: AlertSeverity) => void - dataFieldOrder: string[] - dataFields?: Record< - string, - { render: (props: { label: string; value: unknown }) => JSX.Element } - > -} - -export function AlertsDialog({ - open, - onOpenChange, - dismissOne, - dismissMany, - alerts, - dataFieldOrder, - dataFields, -}: Props) { - const loadingState = useDatasetState({ - datasetPage: alerts.data, - isValidating: alerts.isValidating, - error: alerts.error, - }) - - const [filter, setFilter] = useState() - const dataset = useMemo( - () => - alerts.data?.filter((a) => (filter ? a.severity === filter : true)) || [], - [alerts.data, filter] - ) - // Sort keys by dataFieldOrder, then alphabetically. - const getOrderedKeys = useCallback( - (obj: Record) => { - const orderedKeys = Object.keys(obj).sort((a, b) => { - const aIndex = dataFieldOrder.indexOf(a) - const bIndex = dataFieldOrder.indexOf(b) - if (aIndex === -1 && bIndex === -1) { - return 0 - } - if (aIndex === -1) { - return 1 - } - if (bIndex === -1) { - return -1 - } - return aIndex - bIndex - }) - return orderedKeys - }, - [dataFieldOrder] - ) - - return ( - { - onOpenChange(val) - }} - contentVariants={{ - className: 'w-[500px] h-[80vh]', - }} - title={ -
- - Alerts {alerts.data ? `(${alerts.data.length})` : ''} - -
- - - - - -
- {!loadingState && !!dataset.length && ( - - )} -
-
- } - > -
- {loadingState === 'noneYet' && } - {loadingState === 'error' && ( -
- - {alerts.error.message} - -
- )} - {loadingState === 'loading' && } - {loadingState === 'loaded' && ( -
- {dataset.length ? ( - dataset.map((a) => ( -
-
-
- - {a.severity}: {a.message} - -
- -
-
- - timestamp - - - {humanDate(a.timestamp, { timeStyle: 'medium' })} - -
- {getOrderedKeys(a.data).map((key) => { - const value = a.data[key] - if ( - value === undefined || - value === null || - (typeof value === 'object' && !Object.keys(value).length) - ) { - return null - } - const Component = - dataFields?.[key]?.render || DefaultDisplay - return - })} -
- )) - ) : ( - - )} -
- )} -
-
- ) -} - -function DefaultDisplay({ label, value }: { label: string; value: unknown }) { - return ( -
- {label} - - {String(value)} - -
- ) -} - -function EntityListSkeleton() { - return ( - <> - {times(10, (i) => ( -
-
-
- - -
- - -
-
- ))} - - ) -} - -function itemBorderStyles() { - return cx( - 'border-t border-gray-200 dark:border-graydark-300', - 'first:border-none' - ) -} diff --git a/libs/design-system/src/index.ts b/libs/design-system/src/index.ts index 7508cc0b3..1804cb811 100644 --- a/libs/design-system/src/index.ts +++ b/libs/design-system/src/index.ts @@ -90,7 +90,6 @@ export * from './app/AppPublicLayout' export * from './app/AppAuthedLayout' export * from './app/AppAuthedLayout/SidenavItem' export * from './app/AppBackdrop' -export * from './app/AlertsDialog' export * from './app/AppLogin' export * from './app/AppDockedControl' export * from './app/WalletSendSiacoinDialog' diff --git a/libs/e2e/src/fixtures/click.ts b/libs/e2e/src/fixtures/click.ts index 6cb26c553..c6c3a17fb 100644 --- a/libs/e2e/src/fixtures/click.ts +++ b/libs/e2e/src/fixtures/click.ts @@ -26,6 +26,40 @@ export const clickAndWait = step( } ) +export const continueToClickUntil = step( + 'continue to click until', + async (clickLocator: Locator, waitForLocator: Locator, timeout = 5000) => { + const startTime = Date.now() + while (Date.now() - startTime < timeout) { + // Check if the target locator is already visible. + const isTargetVisible = await waitForLocator + .isVisible({ timeout: 100 }) + .catch(() => false) + if (isTargetVisible) { + break + } + + // Check if the click locator still exists. + const isClickable = await clickLocator + .isVisible({ timeout: 100 }) + .catch(() => false) + if (!isClickable) { + break + } + + try { + await clickLocator.click({ timeout: 100 }) + } catch (e) { + break + } + + // Small delay to prevent too rapid clicking. + await new Promise((resolve) => setTimeout(resolve, 100)) + } + await expect(waitForLocator).toBeVisible() + } +) + export const clickIf = step( 'click if', async (locator: Locator, clickIf: 'isVisible' | 'isDisabled') => { diff --git a/libs/hostd-types/src/api.ts b/libs/hostd-types/src/api.ts index 07367cf9c..fb666da41 100644 --- a/libs/hostd-types/src/api.ts +++ b/libs/hostd-types/src/api.ts @@ -529,35 +529,37 @@ export type SystemDirectoryCreateResponse = void export type AlertSeverity = 'info' | 'warning' | 'error' | 'critical' +export type AlertData = { + contractID?: number + blockHeight?: number + resolution?: string + volume?: string + volumeID?: number + + elapsed?: number + error?: string + + checked?: number + missing?: number + corrupt?: number + total?: number + + oldSectors?: number + currentSectors?: number + targetSectors?: number + migratedSectors?: number + + migrated?: number + target?: number + + force?: boolean +} + export type Alert = { id: string severity: AlertSeverity message: string - data: { - contractID?: number - blockHeight?: number - resolution?: string - volume?: string - volumeID?: number - - elapsed?: number - error?: string - - checked?: number - missing?: number - corrupt?: number - total?: number - - oldSectors?: number - currentSectors?: number - targetSectors?: number - migratedSectors?: number - - migrated?: number - target?: number - - force?: boolean - } + data: AlertData timestamp: string }