From b05a2447ecd6808b71ae90ed71095caf381cebba Mon Sep 17 00:00:00 2001 From: Alex Freska Date: Fri, 10 Jan 2025 10:37:09 -0500 Subject: [PATCH] feat(hostd): upgraded alerts feature --- .changeset/spicy-tips-boil.md | 5 + apps/hostd-e2e/src/fixtures/alerts.ts | 24 ++ apps/hostd-e2e/src/fixtures/navigate.ts | 8 +- apps/hostd-e2e/src/specs/alerts.spec.ts | 84 +++++ apps/hostd-e2e/src/specs/volumes.spec.ts | 9 +- .../components/Alerts/AlertContextMenu.tsx | 54 +++ .../components/Alerts/AlertsActionsMenu.tsx | 9 + .../components/Alerts/AlertsCmd/index.tsx | 55 +++ .../components/Alerts/AlertsFilterMenu.tsx | 96 ++++++ .../Alerts/AlertsViewDropdownMenu.tsx | 78 +++++ apps/hostd/components/Alerts/Layout.tsx | 22 ++ apps/hostd/components/Alerts/StateError.tsx | 16 + .../components/Alerts/StateNoneMatching.tsx | 15 + apps/hostd/components/Alerts/StateNoneYet.tsx | 17 + .../Alerts/__tests__/Alerts.test.tsx | 1 + apps/hostd/components/Alerts/index.tsx | 43 +++ apps/hostd/components/CmdRoot/index.tsx | 15 + apps/hostd/components/HostdSidenav.tsx | 4 +- apps/hostd/config/providers.tsx | 9 +- apps/hostd/config/routes.ts | 5 +- apps/hostd/contexts/alerts/columns.tsx | 153 +++++++++ apps/hostd/contexts/alerts/data.tsx | 252 ++++++++++++++ apps/hostd/contexts/alerts/index.tsx | 203 +++++++++++ apps/hostd/contexts/alerts/types.ts | 32 ++ apps/hostd/contexts/dialog.tsx | 3 - apps/hostd/dialogs/AlertsDialog.tsx | 317 ------------------ apps/hostd/pages/alerts/index.tsx | 9 + apps/renterd/components/Alerts/index.tsx | 16 +- apps/renterd/contexts/alerts/columns.tsx | 6 +- apps/renterd/contexts/alerts/index.tsx | 2 +- internal/cluster/go.mod | 28 +- internal/cluster/go.sum | 17 + .../src/app/AlertsDialog/StateEmpty.tsx | 19 -- .../src/app/AlertsDialog/index.tsx | 262 --------------- libs/design-system/src/index.ts | 1 - libs/e2e/src/fixtures/click.ts | 34 ++ libs/hostd-types/src/api.ts | 52 +-- 37 files changed, 1308 insertions(+), 667 deletions(-) create mode 100644 .changeset/spicy-tips-boil.md create mode 100644 apps/hostd-e2e/src/fixtures/alerts.ts create mode 100644 apps/hostd-e2e/src/specs/alerts.spec.ts create mode 100644 apps/hostd/components/Alerts/AlertContextMenu.tsx create mode 100644 apps/hostd/components/Alerts/AlertsActionsMenu.tsx create mode 100644 apps/hostd/components/Alerts/AlertsCmd/index.tsx create mode 100644 apps/hostd/components/Alerts/AlertsFilterMenu.tsx create mode 100644 apps/hostd/components/Alerts/AlertsViewDropdownMenu.tsx create mode 100644 apps/hostd/components/Alerts/Layout.tsx create mode 100644 apps/hostd/components/Alerts/StateError.tsx create mode 100644 apps/hostd/components/Alerts/StateNoneMatching.tsx create mode 100644 apps/hostd/components/Alerts/StateNoneYet.tsx create mode 100644 apps/hostd/components/Alerts/__tests__/Alerts.test.tsx create mode 100644 apps/hostd/components/Alerts/index.tsx create mode 100644 apps/hostd/contexts/alerts/columns.tsx create mode 100644 apps/hostd/contexts/alerts/data.tsx create mode 100644 apps/hostd/contexts/alerts/index.tsx create mode 100644 apps/hostd/contexts/alerts/types.ts delete mode 100644 apps/hostd/dialogs/AlertsDialog.tsx create mode 100644 apps/hostd/pages/alerts/index.tsx delete mode 100644 libs/design-system/src/app/AlertsDialog/StateEmpty.tsx delete mode 100644 libs/design-system/src/app/AlertsDialog/index.tsx 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..eeef8cd3c --- /dev/null +++ b/apps/hostd-e2e/src/specs/alerts.spec.ts @@ -0,0 +1,84 @@ +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 { clickAndWait, 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.') + ) +}) + +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/__tests__/Alerts.test.tsx b/apps/hostd/components/Alerts/__tests__/Alerts.test.tsx new file mode 100644 index 000000000..0519ecba6 --- /dev/null +++ b/apps/hostd/components/Alerts/__tests__/Alerts.test.tsx @@ -0,0 +1 @@ + \ No newline at end of file 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/HostdSidenav.tsx b/apps/hostd/components/HostdSidenav.tsx index 0922091d8..24bf1d333 100644 --- a/apps/hostd/components/HostdSidenav.tsx +++ b/apps/hostd/components/HostdSidenav.tsx @@ -9,10 +9,8 @@ import { import { useAlerts } from '@siafoundation/hostd-react' import { cx } from 'class-variance-authority' import { routes } from '../config/routes' -import { useDialog } from '../contexts/dialog' export function HostdSidenav() { - const { openDialog } = useDialog() const alerts = useAlerts() const onlyInfoAlerts = !alerts.data?.find((a) => a.severity !== 'info') @@ -56,7 +54,7 @@ export function HostdSidenav() { {alertCount.toLocaleString()} )} - openDialog('alerts')}> + diff --git a/apps/hostd/config/providers.tsx b/apps/hostd/config/providers.tsx index be4754034..2a99ef223 100644 --- a/apps/hostd/config/providers.tsx +++ b/apps/hostd/config/providers.tsx @@ -4,6 +4,7 @@ import { DialogProvider, Dialogs } from '../contexts/dialog' import { VolumesProvider } from '../contexts/volumes' import { ConfigProvider } from '../contexts/config' import { TransactionsProvider } from '../contexts/transactions' +import { AlertsProvider } from '../contexts/alerts' type Props = { children: React.ReactNode @@ -17,10 +18,12 @@ export function Providers({ children }: Props) { - {/* this is here so that dialogs can use all the other providers, + + {/* this is here so that dialogs can use all the other providers, and the other providers can trigger dialogs */} - - {children} + + {children} + diff --git a/apps/hostd/config/routes.ts b/apps/hostd/config/routes.ts index 21b329a71..dae290a53 100644 --- a/apps/hostd/config/routes.ts +++ b/apps/hostd/config/routes.ts @@ -26,6 +26,9 @@ export const routes = { peers: '/node/peers', }, login: '/login', -} + alerts: { + index: '/alerts', + }, +} as const export const connectivityRoute = hostStateRoute diff --git a/apps/hostd/contexts/alerts/columns.tsx b/apps/hostd/contexts/alerts/columns.tsx new file mode 100644 index 000000000..2e4cc6370 --- /dev/null +++ b/apps/hostd/contexts/alerts/columns.tsx @@ -0,0 +1,153 @@ +import { + Badge, + Button, + ControlGroup, + objectEntries, + Panel, + Separator, + TableColumn, + Text, + ExpandableText, +} from '@siafoundation/design-system' +import { AlertData, TableColumnId } from './types' +import { dataFields } from './data' +import { Checkmark16 } from '@siafoundation/react-icons' +import { formatRelative } from 'date-fns' +import { Fragment, useMemo } from 'react' + +type Context = Record + +type AlertsTableColumn = TableColumn & { + fixed?: boolean + category?: string +} + +export const columns: AlertsTableColumn[] = [ + { + id: 'actions', + label: '', + fixed: true, + cellClassName: 'w-[50px] !pr-4 [&+*]:!pl-0', + rowCellClassName: 'align-top pt-[19px]', + render: ({ data: { dismiss } }) => ( + + + {/* */} + + ), + }, + { + 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/internal/cluster/go.mod b/internal/cluster/go.mod index 2fb0db96c..60f26570f 100644 --- a/internal/cluster/go.mod +++ b/internal/cluster/go.mod @@ -5,23 +5,23 @@ go 1.23.1 toolchain go1.23.2 require ( - go.sia.tech/cluster v0.1.3-0.20241219170242-210f73fd5559 + go.sia.tech/cluster v0.1.3-0.20250110150831-166fabf97b5e go.sia.tech/core v0.9.0 - go.sia.tech/coreutils v0.8.1-0.20241219074811-738f2d24b7aa + go.sia.tech/coreutils v0.9.0 go.uber.org/zap v1.27.0 ) require ( github.com/aws/aws-sdk-go v1.55.5 // indirect - github.com/cloudflare/cloudflare-go v0.111.0 // indirect - github.com/gabriel-vasile/mimetype v1.4.7 // indirect - github.com/goccy/go-json v0.10.3 // indirect + github.com/cloudflare/cloudflare-go v0.113.0 // indirect + github.com/gabriel-vasile/mimetype v1.4.8 // indirect + github.com/goccy/go-json v0.10.4 // indirect github.com/google/go-querystring v1.1.0 // indirect - github.com/gotd/contrib v0.20.0 // indirect + github.com/gotd/contrib v0.21.0 // indirect github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect github.com/jmespath/go-jmespath v0.4.0 // indirect github.com/julienschmidt/httprouter v1.3.0 // indirect - github.com/klauspost/cpuid/v2 v2.2.8 // indirect + github.com/klauspost/cpuid/v2 v2.2.9 // indirect github.com/klauspost/reedsolomon v1.12.4 // indirect github.com/mattn/go-sqlite3 v1.14.24 // indirect github.com/montanaflynn/stats v0.7.1 // indirect @@ -30,17 +30,17 @@ require ( github.com/shopspring/decimal v1.4.0 // indirect go.etcd.io/bbolt v1.3.11 // indirect go.sia.tech/gofakes3 v0.0.5 // indirect - go.sia.tech/hostd v1.1.3-0.20241218083322-ae9c8a971fe0 // indirect + go.sia.tech/hostd v1.1.3-0.20250107045637-02030327f2a0 // indirect go.sia.tech/jape v0.12.1 // indirect go.sia.tech/mux v1.3.0 // indirect - go.sia.tech/renterd v1.1.2-0.20241219133535-793a15f41c36 // indirect - go.sia.tech/walletd v0.8.1-0.20241217084958-e4f5880d4d06 // indirect + go.sia.tech/renterd v1.1.2-0.20250110150645-51a18d21b9ff // indirect + go.sia.tech/walletd v0.9.0-beta.1.0.20250107110631-6c40e2b694ed // indirect go.uber.org/multierr v1.11.0 // indirect golang.org/x/crypto v0.31.0 // indirect - golang.org/x/net v0.31.0 // indirect - golang.org/x/sys v0.28.0 // indirect + golang.org/x/net v0.33.0 // indirect + golang.org/x/sys v0.29.0 // indirect golang.org/x/text v0.21.0 // indirect - golang.org/x/time v0.8.0 // indirect - golang.org/x/tools v0.23.0 // indirect + golang.org/x/time v0.9.0 // indirect + golang.org/x/tools v0.28.0 // indirect lukechampine.com/frand v1.5.1 // indirect ) diff --git a/internal/cluster/go.sum b/internal/cluster/go.sum index 32db8cc12..7ceda838c 100644 --- a/internal/cluster/go.sum +++ b/internal/cluster/go.sum @@ -4,15 +4,18 @@ github.com/aws/aws-sdk-go v1.55.5 h1:KKUZBfBoyqy5d3swXyiC7Q76ic40rYcbqH7qjh59kzU github.com/aws/aws-sdk-go v1.55.5/go.mod h1:eRwEWoyTWFMVYVQzKMNHWP5/RV4xIUGMQfXQHfHkpNU= github.com/cloudflare/cloudflare-go v0.111.0 h1:bFgl5OyR7iaV9DkTaoI2jU8X4rXDzEaFDaPfMTp+Ewo= github.com/cloudflare/cloudflare-go v0.111.0/go.mod h1:w5c4Vm00JjZM+W0mPi6QOC+eWLncGQPURtgDck3z5xU= +github.com/cloudflare/cloudflare-go v0.113.0/go.mod h1:Dlm4BAnycHc0i8yLxQZb9b+OlMwYOAoDJsUOEFgpVvo= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/gabriel-vasile/mimetype v1.4.7 h1:SKFKl7kD0RiPdbht0s7hFtjl489WcQ1VyPW8ZzUMYCA= github.com/gabriel-vasile/mimetype v1.4.7/go.mod h1:GDlAgAyIRT27BhFl53XNAFtfjzOkLaF35JdEG0P7LtU= +github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8= github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA= github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/goccy/go-json v0.10.4/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= @@ -20,6 +23,7 @@ github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= github.com/gotd/contrib v0.20.0 h1:1Wc4+HMQiIKYQuGHVwVksIx152HFTP6B5n88dDe0ZYw= github.com/gotd/contrib v0.20.0/go.mod h1:P6o8W4niqhDPHLA0U+SA/L7l3BQHYLULpeHfRSePn9o= +github.com/gotd/contrib v0.21.0/go.mod h1:ENoUh75IhHGxfz/puVJg8BU4ZF89yrL6Q47TyoNqFYo= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg= @@ -30,6 +34,7 @@ github.com/julienschmidt/httprouter v1.3.0 h1:U0609e9tgbseu3rBINet9P48AI/D3oJs4d github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM= github.com/klauspost/cpuid/v2 v2.2.8 h1:+StwCXwm9PdpiEkPyzBXIy+M9KUb4ODm0Zarf1kS5BM= github.com/klauspost/cpuid/v2 v2.2.8/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= +github.com/klauspost/cpuid/v2 v2.2.9/go.mod h1:rqkxqrZ1EhYM9G+hXH7YdowN5R5RGN6NK4QwQ3WMXF8= github.com/klauspost/reedsolomon v1.12.4 h1:5aDr3ZGoJbgu/8+j45KtUJxzYm8k08JGtB9Wx1VQ4OA= github.com/klauspost/reedsolomon v1.12.4/go.mod h1:d3CzOMOt0JXGIFZm1StgkyF14EYr3xneR2rNWo7NcMU= github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM= @@ -51,22 +56,30 @@ go.etcd.io/bbolt v1.3.11 h1:yGEzV1wPz2yVCLsD8ZAiGHhHVlczyC9d1rP43/VCRJ0= go.etcd.io/bbolt v1.3.11/go.mod h1:dksAq7YMXoljX0xu6VF5DMZGbhYYoLUalEiSySYAS4I= go.sia.tech/cluster v0.1.3-0.20241219170242-210f73fd5559 h1:f7wJbHXcbdVx0NGkUL6t/Yt7nCqHCCgcdnPcrinvqIU= go.sia.tech/cluster v0.1.3-0.20241219170242-210f73fd5559/go.mod h1:vk+Gl3l/eK+kL9HWprnB8qO2wGUCraaa3XlxPmzDQjU= +go.sia.tech/cluster v0.1.3-0.20250110150831-166fabf97b5e h1:bVytg5LsJtlBq7ISWA4dMrr9KQz0ugNgNmyNOJkVapk= +go.sia.tech/cluster v0.1.3-0.20250110150831-166fabf97b5e/go.mod h1:NvY+eWsT4OPn2SvySlMmE/qHIeLV5GS3hCQHG5R89cQ= go.sia.tech/core v0.9.0 h1:qV7V8nkNaPvBEhkbwgrETTkb7JCMcAnKUQt9nUumP4k= go.sia.tech/core v0.9.0/go.mod h1:3NAvYHuzAZg9vP6pyIMOxjTkgHBQ3vx9cXTqRF6oEa4= go.sia.tech/coreutils v0.8.1-0.20241219074811-738f2d24b7aa h1:YGyvxTwneBe64JXQv2iWb54aJIWPFtoz3i0HUE2/7xs= go.sia.tech/coreutils v0.8.1-0.20241219074811-738f2d24b7aa/go.mod h1:xmOx4l1CtArSguaANf0h9J9Mjw+8w7MBp/95Yz0pwq8= +go.sia.tech/coreutils v0.9.0/go.mod h1:KFq1q5/YbPH6ZSWtXCxA1bRhBF5Zgcj8G3Wvu0jr/BA= go.sia.tech/gofakes3 v0.0.5 h1:vFhVBUFbKE9ZplvLE2w4TQxFMQyF8qvgxV4TaTph+Vw= go.sia.tech/gofakes3 v0.0.5/go.mod h1:LXEzwGw+OHysWLmagleCttX93cJZlT9rBu/icOZjQ54= go.sia.tech/hostd v1.1.3-0.20241218083322-ae9c8a971fe0 h1:QtF8l+pHZq6gPyDyuQoMv8GdwU6lvz39y4I34S3cuvo= go.sia.tech/hostd v1.1.3-0.20241218083322-ae9c8a971fe0/go.mod h1:9jRImPfriQKypd7O6O46BQzRkyx+0tRabNKxQxJxDR8= +go.sia.tech/hostd v1.1.3-0.20250107045637-02030327f2a0 h1:Aln+hBgGt950UseeJzqFrEXG8zlAuPDo93RrTCkskC0= +go.sia.tech/hostd v1.1.3-0.20250107045637-02030327f2a0/go.mod h1:g+ZJlcoidPqD9vY307uKXGx3HbLPT0bKX5UHwLscvJw= go.sia.tech/jape v0.12.1 h1:xr+o9V8FO8ScRqbSaqYf9bjj1UJ2eipZuNcI1nYousU= go.sia.tech/jape v0.12.1/go.mod h1:wU+h6Wh5olDjkPXjF0tbZ1GDgoZ6VTi4naFw91yyWC4= go.sia.tech/mux v1.3.0 h1:hgR34IEkqvfBKUJkAzGi31OADeW2y7D6Bmy/Jcbop9c= go.sia.tech/mux v1.3.0/go.mod h1:I46++RD4beqA3cW9Xm9SwXbezwPqLvHhVs9HLpDtt58= go.sia.tech/renterd v1.1.2-0.20241219133535-793a15f41c36 h1:2u2ILSx0FmJlyjLEOqaNithsTgruR7YXtVE/oVK8pt4= go.sia.tech/renterd v1.1.2-0.20241219133535-793a15f41c36/go.mod h1:1Xa+c+sd4g7MR3p+itdpuoLH9KCfYrRldbZKpypwbxw= +go.sia.tech/renterd v1.1.2-0.20250110150645-51a18d21b9ff/go.mod h1:JWQUM79MksI6M8gIddipu419zXnwR2d8fiqkSiuzf10= go.sia.tech/walletd v0.8.1-0.20241217084958-e4f5880d4d06 h1:Ic47H0RUOGlfFqQbigIdWpDzm10aqCAQTAQajb+9vdA= go.sia.tech/walletd v0.8.1-0.20241217084958-e4f5880d4d06/go.mod h1:ylDVaCuJ2kuCHwzHAxS4Xbf0Z74CigPXSdI8hvoDbJE= +go.sia.tech/walletd v0.9.0-beta.1.0.20250107110631-6c40e2b694ed h1:jbM/Trr+dwra9vtocshagrdvKkbHuAO+Ys2HFntnXcw= +go.sia.tech/walletd v0.9.0-beta.1.0.20250107110631-6c40e2b694ed/go.mod h1:PMGwnVXHA9Az7Y3P34ng8bZbW+E3W45ZRJVy9wADvpw= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= @@ -81,6 +94,7 @@ golang.org/x/mod v0.19.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.31.0 h1:68CPQngjLL0r2AlUKiSxtQFKvzRVbnzLwMUn5SzcLHo= golang.org/x/net v0.31.0/go.mod h1:P4fl1q7dY2hnZFxEk4pPSkDHF+QqjitcnDjUQyMM+pM= +golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ= golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= @@ -88,14 +102,17 @@ golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5h golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= golang.org/x/time v0.8.0 h1:9i3RxcPv3PZnitoVGMPDKZSq1xW1gK1Xy3ArNOGZfEg= golang.org/x/time v0.8.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/tools v0.0.0-20190829051458-42f498d34c4d/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.23.0 h1:SGsXPZ+2l4JsgaCKkx+FQ9YZ5XEtA1GZYuoDjenLjvg= golang.org/x/tools v0.23.0/go.mod h1:pnu6ufv6vQkll6szChhK3C3L/ruaIv5eBeztNG8wtsI= +golang.org/x/tools v0.28.0/go.mod h1:dcIOrVd3mfQKTgrDVQHqCPMWy6lnhfhtX3hLXYVLfRw= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 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 }