diff --git a/.changeset/fuzzy-dolphins-do.md b/.changeset/fuzzy-dolphins-do.md new file mode 100644 index 000000000..24a01e388 --- /dev/null +++ b/.changeset/fuzzy-dolphins-do.md @@ -0,0 +1,5 @@ +--- +'@siafoundation/design-system': minor +--- + +Added a rowSize auto variant to Table. diff --git a/.changeset/quiet-pens-bow.md b/.changeset/quiet-pens-bow.md new file mode 100644 index 000000000..b98f059ad --- /dev/null +++ b/.changeset/quiet-pens-bow.md @@ -0,0 +1,5 @@ +--- +'@siafoundation/design-system': minor +--- + +Added a custom contextMenu prop to ValueCopyable. diff --git a/.changeset/real-crabs-speak.md b/.changeset/real-crabs-speak.md new file mode 100644 index 000000000..174fc3dcc --- /dev/null +++ b/.changeset/real-crabs-speak.md @@ -0,0 +1,5 @@ +--- +'@siafoundation/design-system': minor +--- + +Added a subtle variant to Panel. diff --git a/.changeset/seven-terms-thank.md b/.changeset/seven-terms-thank.md new file mode 100644 index 000000000..7e545825a --- /dev/null +++ b/.changeset/seven-terms-thank.md @@ -0,0 +1,5 @@ +--- +'renterd': minor +--- + +Alerts now support pagination. Closes https://github.com/SiaFoundation/renterd/issues/1001 Closes https://github.com/SiaFoundation/renterd/issues/862 diff --git a/.changeset/tasty-rocks-compete.md b/.changeset/tasty-rocks-compete.md new file mode 100644 index 000000000..790202716 --- /dev/null +++ b/.changeset/tasty-rocks-compete.md @@ -0,0 +1,5 @@ +--- +'renterd': minor +--- + +Alerts now support the accumulated churn alert. Closes https://github.com/SiaFoundation/renterd/issues/1005 diff --git a/.changeset/twenty-snakes-look.md b/.changeset/twenty-snakes-look.md new file mode 100644 index 000000000..c6863fc78 --- /dev/null +++ b/.changeset/twenty-snakes-look.md @@ -0,0 +1,5 @@ +--- +'renterd': minor +--- + +Alerts now have a dedicated tab with a larger area for display and navigation. diff --git a/apps/renterd/components/Alerts/AlertContextMenu.tsx b/apps/renterd/components/Alerts/AlertContextMenu.tsx new file mode 100644 index 000000000..e637fb44b --- /dev/null +++ b/apps/renterd/components/Alerts/AlertContextMenu.tsx @@ -0,0 +1,54 @@ +import { + DropdownMenu, + DropdownMenuItem, + Button, + DropdownMenuLeftSlot, + DropdownMenuLabel, + Text, +} from '@siafoundation/design-system' +import { Draggable16, 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/renterd/components/Alerts/AlertsActionsMenu.tsx b/apps/renterd/components/Alerts/AlertsActionsMenu.tsx new file mode 100644 index 000000000..84f2b5acc --- /dev/null +++ b/apps/renterd/components/Alerts/AlertsActionsMenu.tsx @@ -0,0 +1,9 @@ +import { AlertsViewDropdownMenu } from './AlertsViewDropdownMenu' + +export function AlertsActionsMenu() { + return ( +
+ +
+ ) +} diff --git a/apps/renterd/components/Alerts/AlertsCmd/index.tsx b/apps/renterd/components/Alerts/AlertsCmd/index.tsx new file mode 100644 index 000000000..bfd198d76 --- /dev/null +++ b/apps/renterd/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/renterd/components/Alerts/AlertsFilterMenu.tsx b/apps/renterd/components/Alerts/AlertsFilterMenu.tsx new file mode 100644 index 000000000..c61c51b9e --- /dev/null +++ b/apps/renterd/components/Alerts/AlertsFilterMenu.tsx @@ -0,0 +1,74 @@ +'use client' + +import { Button, PaginatorKnownTotal, Text } from '@siafoundation/design-system' +import { useAlerts } from '../../contexts/alerts' +import { Checkmark16 } from '@carbon/icons-react' + +export function AlertsFilterMenu() { + const { + severityFilter, + setSeverityFilter, + offset, + limit, + totals, + pageCount, + dataState, + datasetPage, + dismissMany, + } = useAlerts() + + return ( +
+ Filter +
+ + + + + +
+
+ {!dataState && !!pageCount && ( + + )} + +
+ ) +} diff --git a/apps/renterd/components/Alerts/AlertsViewDropdownMenu.tsx b/apps/renterd/components/Alerts/AlertsViewDropdownMenu.tsx new file mode 100644 index 000000000..09577829a --- /dev/null +++ b/apps/renterd/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, + enabledColumns, + } = 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={enabledColumns} + setColumnsVisible={setColumnsVisible} + setColumnsHidden={setColumnsHidden} + /> + + toggleColumnVisibility(value)} + /> + + + ) +} diff --git a/apps/renterd/components/Alerts/StateError.tsx b/apps/renterd/components/Alerts/StateError.tsx new file mode 100644 index 000000000..164324f3c --- /dev/null +++ b/apps/renterd/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/renterd/components/Alerts/StateNoneMatching.tsx b/apps/renterd/components/Alerts/StateNoneMatching.tsx new file mode 100644 index 000000000..c97352875 --- /dev/null +++ b/apps/renterd/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/renterd/components/Alerts/StateNoneYet.tsx b/apps/renterd/components/Alerts/StateNoneYet.tsx new file mode 100644 index 000000000..98e2d36c1 --- /dev/null +++ b/apps/renterd/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 currenty no alerts. + +
+
+ ) +} diff --git a/apps/renterd/components/Alerts/index.tsx b/apps/renterd/components/Alerts/index.tsx new file mode 100644 index 000000000..b7853b845 --- /dev/null +++ b/apps/renterd/components/Alerts/index.tsx @@ -0,0 +1,61 @@ +import { RenterdSidenav } from '../RenterdSidenav' +import { routes } from '../../config/routes' +import { Table } from '@siafoundation/design-system' +import { useDialog } from '../../contexts/dialog' +import { RenterdAuthedLayout } from '../RenterdAuthedLayout' +import { StateNoneMatching } from './StateNoneMatching' +import { StateNoneYet } from './StateNoneYet' +import { AlertsActionsMenu } from './AlertsActionsMenu' +import { StateError } from './StateError' +import { useAlerts } from '../../contexts/alerts' +import { AlertsFilterMenu } from './AlertsFilterMenu' + +export function Alerts() { + const { openDialog } = useDialog() + const { + columns, + datasetPage, + sortField, + sortDirection, + sortableColumns, + toggleSort, + limit, + dataState, + cellContext, + } = useAlerts() + + return ( + } + openSettings={() => openDialog('settings')} + actions={} + stats={} + > +
+ + ) : dataState === 'noneYet' ? ( + + ) : dataState === 'error' ? ( + + ) : null + } + sortableColumns={sortableColumns} + pageSize={limit} + data={datasetPage} + columns={columns} + sortDirection={sortDirection} + sortField={sortField} + toggleSort={toggleSort} + rowSize="auto" + /> + + + ) +} diff --git a/apps/renterd/components/CmdRoot/index.tsx b/apps/renterd/components/CmdRoot/index.tsx index cfdb2ada8..cfc7a012b 100644 --- a/apps/renterd/components/CmdRoot/index.tsx +++ b/apps/renterd/components/CmdRoot/index.tsx @@ -15,6 +15,7 @@ import { useDialog } from '../../contexts/dialog' import { useRouter } from 'next/router' import { routes } from '../../config/routes' import { ContractsCmd } from '../Contracts/ContractsCmd' +import { AlertsCmd } from '../Alerts/AlertsCmd' import { Page } from './types' import { useContracts } from '../../contexts/contracts' import { HostsCmd } from '../Hosts/HostsCmd' @@ -145,6 +146,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/renterd/components/Contracts/ContractContextMenu.tsx b/apps/renterd/components/Contracts/ContractContextMenu.tsx index 0a3671ec1..d3549190f 100644 --- a/apps/renterd/components/Contracts/ContractContextMenu.tsx +++ b/apps/renterd/components/Contracts/ContractContextMenu.tsx @@ -30,6 +30,7 @@ import { useContractConfirmDelete } from './useContractConfirmDelete' type Props = { id: string + trigger?: React.ReactNode address: string publicKey: string contentProps?: React.ComponentProps['contentProps'] @@ -38,6 +39,7 @@ type Props = { export function ContractContextMenu({ id, + trigger, address, publicKey, contentProps, @@ -56,9 +58,11 @@ export function ContractContextMenu({ return ( - - + trigger || ( + + ) } contentProps={{ align: 'start', diff --git a/apps/renterd/components/Contracts/ContractContextMenuFromId.tsx b/apps/renterd/components/Contracts/ContractContextMenuFromId.tsx index cbe88a9ff..f548e9946 100644 --- a/apps/renterd/components/Contracts/ContractContextMenuFromId.tsx +++ b/apps/renterd/components/Contracts/ContractContextMenuFromId.tsx @@ -1,5 +1,5 @@ import { Button } from '@siafoundation/design-system' -import { Draggable16 } from '@siafoundation/react-icons' +import { CaretDown16 } from '@siafoundation/react-icons' import { useContract } from '@siafoundation/react-renterd' import { ContractContextMenu } from './ContractContextMenu' @@ -22,8 +22,14 @@ export function ContractContextMenuFromId({ if (!contract.data) { return ( - ) } @@ -34,6 +40,11 @@ export function ContractContextMenuFromId({ publicKey={contract.data.hostKey} contentProps={contentProps} buttonProps={buttonProps} + trigger={ + + } /> ) } diff --git a/apps/renterd/components/Hosts/HostContextMenu.tsx b/apps/renterd/components/Hosts/HostContextMenu.tsx index df5357ffe..e627c42be 100644 --- a/apps/renterd/components/Hosts/HostContextMenu.tsx +++ b/apps/renterd/components/Hosts/HostContextMenu.tsx @@ -38,6 +38,7 @@ type Props = { publicKey: string contentProps?: React.ComponentProps['contentProps'] buttonProps?: React.ComponentProps + trigger?: React.ReactNode } export function HostContextMenu({ @@ -45,6 +46,7 @@ export function HostContextMenu({ publicKey, contentProps, buttonProps, + trigger, }: Props) { const router = useRouter() const { setFilter: setHostsFilter, resetFilters: resetHostsFilters } = @@ -60,9 +62,11 @@ export function HostContextMenu({ return ( - - + trigger || ( + + ) } contentProps={{ align: 'start', diff --git a/apps/renterd/components/Hosts/HostContextMenuFromId.tsx b/apps/renterd/components/Hosts/HostContextMenuFromId.tsx new file mode 100644 index 000000000..72e3ff4da --- /dev/null +++ b/apps/renterd/components/Hosts/HostContextMenuFromId.tsx @@ -0,0 +1,47 @@ +import { Button } from '@siafoundation/design-system' +import { CaretDown16 } from '@siafoundation/react-icons' +import { useHost } from '@siafoundation/react-renterd' +import { HostContextMenu } from './HostContextMenu' + +type Props = { + hostKey: string + contentProps?: React.ComponentProps['contentProps'] + buttonProps?: React.ComponentProps +} + +export function HostContextMenuFromKey({ + hostKey, + contentProps, + buttonProps, +}: Props) { + const host = useHost({ + params: { hostKey }, + }) + + if (!host.data) { + return ( + + ) + } + return ( + + + + } + /> + ) +} diff --git a/apps/renterd/components/RenterdSidenav.tsx b/apps/renterd/components/RenterdSidenav.tsx index 5f0349be1..46a26aefd 100644 --- a/apps/renterd/components/RenterdSidenav.tsx +++ b/apps/renterd/components/RenterdSidenav.tsx @@ -7,22 +7,15 @@ import { BellIcon, KeyIcon, } from '@siafoundation/react-icons' -import { useAlerts } from '@siafoundation/react-renterd' import { cx } from 'class-variance-authority' import { routes } from '../config/routes' -import { useDialog } from '../contexts/dialog' +import { useAlerts } from '../contexts/alerts' export function RenterdSidenav() { - const alerts = useAlerts() - const { openDialog } = useDialog() - - const onlyInfoAlerts = !alerts.data?.find((a) => a.severity !== 'info') - const alertCount = alerts.data?.length || 0 + const { totals } = useAlerts() + const onlyInfoAlerts = totals.all === totals.info return ( <> - {/* - - */} @@ -39,31 +32,32 @@ export function RenterdSidenav() {
- {!!alertCount && onlyInfoAlerts && ( -
- )} - {!!alertCount && !onlyInfoAlerts && ( - - {alertCount.toLocaleString()} - - )} - openDialog('alerts')}> + {totals.all ? ( + onlyInfoAlerts ? ( +
+ ) : ( + + {totals.all.toLocaleString()} + + ) + ) : null} +
diff --git a/apps/renterd/config/providers.tsx b/apps/renterd/config/providers.tsx index 7f0e4b633..413a68929 100644 --- a/apps/renterd/config/providers.tsx +++ b/apps/renterd/config/providers.tsx @@ -12,6 +12,7 @@ import { FilesFlatProvider } from '../contexts/filesFlat' import { FilesManagerProvider } from '../contexts/filesManager' import { FilesDirectoryProvider } from '../contexts/filesDirectory' import { UploadsProvider } from '../contexts/uploads' +import { AlertsProvider } from '../contexts/alerts' type Props = { children: React.ReactNode @@ -30,12 +31,14 @@ 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/renterd/config/routes.ts b/apps/renterd/config/routes.ts index f2071d328..7cf27a19d 100644 --- a/apps/renterd/config/routes.ts +++ b/apps/renterd/config/routes.ts @@ -29,6 +29,9 @@ export const routes = { keys: { index: '/keys', }, + alerts: { + index: '/alerts', + }, node: { index: '/node', }, diff --git a/apps/renterd/contexts/alerts/columns.tsx b/apps/renterd/contexts/alerts/columns.tsx new file mode 100644 index 000000000..56fcceb5d --- /dev/null +++ b/apps/renterd/contexts/alerts/columns.tsx @@ -0,0 +1,137 @@ +import { + Badge, + Button, + ControlGroup, + Panel, + Separator, + TableColumn, + Text, +} from '@siafoundation/design-system' +import { AlertData, TableColumnId } from './types' +import { dataFields } from './data' +import { Checkmark16 } from '@carbon/icons-react' +import { formatRelative } from 'date-fns' +import { Fragment, useMemo } from 'react' + +type Context = never + +type KeysTableColumn = TableColumn & { + fixed?: boolean + category?: string +} + +export const columns: KeysTableColumn[] = [ + { + id: 'actions', + label: '', + fixed: true, + cellClassName: 'w-[50px] !pr-4 [&+*]:!pl-0', + render: ({ data: { dismiss } }) => ( + + + {/* */} + + ), + }, + { + id: 'overview', + label: 'overview', + category: 'general', + contentClassName: 'min-w-[200px] max-w-[500px]', + render: ({ data: { message, severity, data } }) => { + return ( +
+
+ + {severity} + + + {message} + +
+ {data['hint'] && ( + + {data['hint'] as string} + + )} + {data['error'] && ( + + {data['error'] as string} + + )} +
+ ) + }, + }, + { + id: 'data', + label: 'data', + contentClassName: 'w-[400px]', + category: 'general', + render: function DataColumn({ data: { data } }) { + const datums = useMemo( + () => + Object.keys(dataFields) + .map((key) => { + const value = data[key] + if ( + value === undefined || + value === null || + (typeof value === 'object' && !Object.keys(value).length) + ) { + return null + } + return { key, value } + }) + .filter(Boolean), + [data] + ) + return ( +
+ + {datums.map(({ key, value }, i) => { + const Component = dataFields?.[key]?.render + if (!Component) { + return null + } + return ( + +
+ +
+ {datums.length > 1 && i < datums.length - 1 && ( + + )} +
+ ) + })} +
+
+ ) + }, + }, + { + id: 'time', + label: 'time', + category: 'general', + contentClassName: 'w-[120px] justify-end', + render: ({ data: { timestamp } }) => { + return ( + + {formatRelative(new Date(), new Date(timestamp))} + + ) + }, + }, +] diff --git a/apps/renterd/contexts/alerts/data.tsx b/apps/renterd/contexts/alerts/data.tsx new file mode 100644 index 000000000..cf5e35847 --- /dev/null +++ b/apps/renterd/contexts/alerts/data.tsx @@ -0,0 +1,386 @@ +import { + Link, + ScrollArea, + Text, + Tooltip, + ValueCopyable, + ValueMenu, + ValueSc, +} from '@siafoundation/design-system' +import { useHost, useSlabObjects } from '@siafoundation/react-renterd' +import { HostContextMenu } from '../../components/Hosts/HostContextMenu' +import { useFilesManager } from '../filesManager' +import { useDialog } from '../dialog' +import { getDirectorySegmentsFromPath } from '../../lib/paths' +import BigNumber from 'bignumber.js' +import { HostContextMenuFromKey } from '../../components/Hosts/HostContextMenuFromId' +import { ContractContextMenuFromId } from '../../components/Contracts/ContractContextMenuFromId' +import { humanBytes } from '@siafoundation/units' +import { formatRelative } from 'date-fns' + +export const dataFields: Record< + string, + { render: (props: { value: unknown }) => JSX.Element } +> = { + origin: { + render: function OriginField({ value }: { value: string }) { + return ( +
+ + origin + + + {value} + +
+ ) + }, + }, + contractID: { + render: function ContractField({ value }: { value: string }) { + return ( +
+ + contract ID + + + } + /> +
+ ) + }, + }, + accountID: { + render: function AccountField({ value }: { value: string }) { + return ( +
+ + account ID + + +
+ ) + }, + }, + hostKey: { + render: function HostField({ value }: { value: string }) { + const host = useHost({ params: { hostKey: value } }) + if (!host.data) { + return null + } + return ( +
+ + host key + + + } + /> +
+ ) + }, + }, + slabKey: { + render: function SlabField({ value }: { value: string }) { + const { setActiveDirectory } = useFilesManager() + const { closeDialog } = useDialog() + const objects = useSlabObjects({ + params: { + key: value, + }, + config: { + swr: { + revalidateOnFocus: false, + }, + }, + }) + return ( +
+
+ + key + + +
+ {objects.data && ( + +
+ {objects.data.map((o) => ( + { + setActiveDirectory(() => + getDirectorySegmentsFromPath(o.name) + ) + closeDialog() + }} + > + {o.name} + + ))} +
+
+ )} +
+ ) + }, + }, + added: { + render: ({ value }: { value: number }) => ( +
+ + added + + + {value} + +
+ ), + }, + removed: { + render: ({ value }: { value: number }) => ( +
+ + removed + + + {value} + +
+ ), + }, + setAdditions: { + render: function AdditionsField({ + value, + }: { + value: Record< + string, + { + hostKey: string + additions: { size: number; time: string }[] + } + > + }) { + return ( +
+ + contract set additions + + {value && ( +
+ {Object.entries(value).map( + ([contractId, { hostKey, additions }], i) => ( + + ) + )} +
+ )} +
+ ) + }, + }, + setRemovals: { + render: function RemovalsField({ + value, + }: { + value: Record< + string, + { + hostKey: string + removals: { reasons: string; size: number; time: string }[] + } + > + }) { + return ( +
+ + contract set removals + + {value && ( +
+ {Object.entries(value).map( + ([contractId, { hostKey, removals }], i) => ( + + ) + )} +
+ )} +
+ ) + }, + }, + migrationsInterrupted: { + render: ({ value }: { value: string }) => ( +
+ + migrations interrupted + + + {value ? 'yes' : 'no'} + +
+ ), + }, + allowance: { + render: ({ value }: { value: string }) => ( +
+ + allowance + + +
+ ), + }, + balance: { + render: ({ value }: { value: string }) => ( +
+ + balance + + +
+ ), + }, + address: { + render: ({ value }: { value: string }) => ( +
+ + address + + +
+ ), + }, + account: { + render: ({ value }: { value: string }) => ( +
+ + account + + +
+ ), + }, +} + +function ContractSetChange({ + contractId, + hostKey, + changes, + i, +}: { + contractId: string + hostKey: string + changes: { reasons?: string; size: number; time: string }[] + i: number +}) { + return ( +
+
+ + {i + 1}. + +
+ + contract + + + } + /> +
+
+ + host + + + } + /> +
+
+ {changes.map(({ reasons, size, time }) => ( +
+ + + {reasons} + + +
+
+ + time + + + {formatRelative(new Date(), new Date(time))} + +
+
+ + size + + + {humanBytes(size)} + +
+
+ ))} +
+ ) +} diff --git a/apps/renterd/contexts/alerts/index.tsx b/apps/renterd/contexts/alerts/index.tsx new file mode 100644 index 000000000..fd21f9875 --- /dev/null +++ b/apps/renterd/contexts/alerts/index.tsx @@ -0,0 +1,219 @@ +import { + useTableState, + useDatasetEmptyState, + useServerFilters, +} from '@siafoundation/design-system' +import { useRouter } from 'next/router' +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 { + AlertSeverity, + AlertsParams, + useAlerts as useAlertsData, + useAlertsDismiss, +} from '@siafoundation/react-renterd' +import { useCallback } from 'react' + +const defaultLimit = 50 + +function useAlertsMain() { + const router = useRouter() + const limit = Number(router.query.limit || defaultLimit) + const offset = Number(router.query.offset || 0) + const { filters, setFilter, removeFilter, removeLastFilter, resetFilters } = + useServerFilters() + + const setSeverityFilter = useCallback( + (severity?: AlertSeverity) => { + if (!severity) { + removeFilter('severity') + } else { + setFilter({ + id: 'severity', + value: severity, + label: severity, + }) + } + }, + [setFilter, removeFilter] + ) + + const severityFilter = filters.find((f) => f.id === 'severity') + ?.value as AlertSeverity + + const params = useMemo(() => { + const params: AlertsParams = { + limit, + offset, + } + if (severityFilter) { + params.severity = severityFilter + } + return params + }, [limit, offset, severityFilter]) + + const response = useAlertsData({ + params, + config: { + swr: { + refreshInterval: defaultDatasetRefreshInterval, + }, + }, + }) + const dismiss = useAlertsDismiss() + + const dismissOne = useCallback( + async (id: string) => { + const response = await dismiss.post({ + payload: [id], + }) + if (response.error) { + triggerErrorToast('Error dismissing alert.') + } else { + triggerSuccessToast('Alert has been dismissed.') + } + }, + [dismiss] + ) + + const dismissMany = useCallback( + async (ids: string[]) => { + const response = await dismiss.post({ + payload: ids, + }) + if (response.error) { + triggerErrorToast('Error dismissing alerts.') + } else { + triggerSuccessToast('Selected alerts have been dismissed.') + } + }, + [dismiss] + ) + + const datasetPage = useMemo(() => { + if (!response.data) { + return null + } + const data: AlertData[] = + response.data?.alerts?.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 { + configurableColumns, + enabledColumns, + sortableColumns, + toggleColumnVisibility, + setColumnsVisible, + setColumnsHidden, + toggleSort, + setSortDirection, + setSortField, + sortField, + sortDirection, + resetDefaultColumnVisibility, + } = useTableState('renterd/v0/keys', { + columns, + columnsDefaultVisible, + sortOptions, + defaultSortField, + }) + + const filteredTableColumns = useMemo( + () => + columns.filter( + (column) => column.fixed || enabledColumns.includes(column.id) + ), + [enabledColumns] + ) + + const dataState = useDatasetEmptyState( + datasetPage, + response.isValidating, + response.error, + filters + ) + + const cellContext = useMemo(() => ({}), []) + + const totals = useMemo( + () => ({ + ...response.data?.totals, + all: Object.entries(response.data?.totals || {}).reduce( + (acc, [severity, count]) => { + return acc + count + }, + 0 + ), + }), + [response.data] + ) + + return { + dataState, + limit, + offset, + isLoading: response.isLoading, + error: response.error, + pageCount: datasetPage?.length || 0, + totals, + columns: filteredTableColumns, + datasetPage, + cellContext, + configurableColumns, + enabledColumns, + sortableColumns, + toggleColumnVisibility, + setColumnsVisible, + setColumnsHidden, + toggleSort, + setSortDirection, + setSortField, + sortField, + filters, + setFilter, + removeFilter, + removeLastFilter, + resetFilters, + sortDirection, + resetDefaultColumnVisibility, + dismissOne, + dismissMany, + severityFilter, + setSeverityFilter, + } +} + +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/renterd/contexts/alerts/types.ts b/apps/renterd/contexts/alerts/types.ts new file mode 100644 index 000000000..e3ee3c8d2 --- /dev/null +++ b/apps/renterd/contexts/alerts/types.ts @@ -0,0 +1,29 @@ +export type AlertSeverity = 'info' | 'warning' | 'error' | 'critical' + +export type AlertData = { + id: string + severity: AlertSeverity + message: string + timestamp: string + data: Record + 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/renterd/contexts/dialog.tsx b/apps/renterd/contexts/dialog.tsx index 7c73f35ac..e16811839 100644 --- a/apps/renterd/contexts/dialog.tsx +++ b/apps/renterd/contexts/dialog.tsx @@ -16,7 +16,6 @@ import { FilesSearchDialog } from '../dialogs/FilesSearchDialog' import { useSyncerConnect, useWallet } from '@siafoundation/react-renterd' import { RenterdSendSiacoinDialog } from '../dialogs/RenterdSendSiacoinDialog' import { RenterdTransactionDetailsDialog } from '../dialogs/RenterdTransactionDetailsDialog' -import { AlertsDialog } from '../dialogs/AlertsDialog' import { HostsFilterPublicKeyDialog } from '../components/Hosts/HostsFilterPublicKeyDialog' import { FilesBucketDeleteDialog } from '../dialogs/FilesBucketDeleteDialog' import { FilesBucketPolicyDialog } from '../dialogs/FilesBucketPolicyDialog' @@ -47,7 +46,6 @@ export type DialogType = | 'filesSearch' | 'fileRename' | 'keysCreate' - | 'alerts' | 'confirm' type ConfirmProps = { @@ -212,10 +210,6 @@ export function Dialogs() { open={dialog === 'contractsFilterPublicKey'} onOpenChange={(val) => (val ? openDialog(dialog) : closeDialog())} /> - (val ? openDialog(dialog) : closeDialog())} - /> (val ? openDialog(dialog) : closeDialog())} diff --git a/apps/renterd/contexts/uploads/index.tsx b/apps/renterd/contexts/uploads/index.tsx index 0b0ec0df6..a24acf3db 100644 --- a/apps/renterd/contexts/uploads/index.tsx +++ b/apps/renterd/contexts/uploads/index.tsx @@ -76,8 +76,6 @@ function useUploadsMain() { ) }, [uploadsMap, activeBucket, response.data, apiBusUploadAbort]) - console.log(dataset) - const { configurableColumns, enabledColumns, diff --git a/apps/renterd/dialogs/AlertsDialog.tsx b/apps/renterd/dialogs/AlertsDialog.tsx deleted file mode 100644 index 92169f120..000000000 --- a/apps/renterd/dialogs/AlertsDialog.tsx +++ /dev/null @@ -1,359 +0,0 @@ -import { - Accordion, - AccordionContent, - AccordionItem, - AccordionTrigger, - AlertsDialog as DSAlertsDialog, - Link, - ScrollArea, - Text, - Tooltip, - triggerErrorToast, - triggerSuccessToast, - ValueCopyable, - ValueMenu, - ValueSc, -} from '@siafoundation/design-system' -import { - AlertSeverity, - useAlerts, - useAlertsDismiss, - useHost, - useSlabObjects, -} from '@siafoundation/react-renterd' -import BigNumber from 'bignumber.js' -import { useCallback } from 'react' -import { ContractContextMenuFromId } from '../components/Contracts/ContractContextMenuFromId' -import { HostContextMenu } from '../components/Hosts/HostContextMenu' -import { useDialog } from '../contexts/dialog' -import { useFilesManager } from '../contexts/filesManager' -import { getDirectorySegmentsFromPath } from '../lib/paths' -import { defaultDatasetRefreshInterval } from '../config/swr' - -type Props = { - open: boolean - onOpenChange: (val: boolean) => 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('Error dismissing alert.') - } else { - triggerSuccessToast('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( - filter - ? `Error dismissing all ${filter} alerts.` - : 'Error dismissing all alerts.' - ) - } else { - triggerSuccessToast( - filter - ? `All ${filter} alerts have been dismissed.` - : 'All alerts have been dismissed.' - ) - } - }, - [dismiss, alerts.data] - ) - - return ( - { - onOpenChange(val) - }} - alerts={alerts} - dataFieldOrder={dataFieldOrder} - dataFields={dataFields} - dismissMany={dismissMany} - dismissOne={dismissOne} - /> - ) -} - -const dataFieldOrder = [ - 'hint', - 'error', - 'origin', - 'hostKey', - 'contractID', - 'accountID', - 'slabKey', - 'additions', - 'removals', -] - -const dataFields: Record< - string, - { render: (props: { value: unknown }) => JSX.Element } -> = { - contractID: { - render: function ContractField({ value }: { value: string }) { - return ( -
- - contract ID - - - } - /> -
- ) - }, - }, - accountID: { - render: function AccountField({ value }: { value: string }) { - return ( -
- - account ID - - -
- ) - }, - }, - hostKey: { - render: function HostField({ value }: { value: string }) { - const host = useHost({ params: { hostKey: value } }) - if (!host.data) { - return null - } - return ( -
- - host key - - - } - /> -
- ) - }, - }, - slabKey: { - render: function SlabField({ value }: { value: string }) { - const { setActiveDirectory } = useFilesManager() - const { closeDialog } = useDialog() - const objects = useSlabObjects({ - params: { - key: value, - }, - config: { - swr: { - revalidateOnFocus: false, - }, - }, - }) - return ( - <> -
- - key - - -
- {objects.data && ( - -
- {objects.data.map((o) => ( - { - setActiveDirectory(() => - getDirectorySegmentsFromPath(o.name) - ) - closeDialog() - }} - > - {o.name} - - ))} -
-
- )} - - ) - }, - }, - additions: { - render: function AdditionsField({ value }: { value: string[] }) { - return ( - <> -
- - additions - -
- {value && ( - -
- {value.map((contractId) => ( -
- -
- ))} -
-
- )} - - ) - }, - }, - removals: { - render: function RemovalsField({ - value, - }: { - value: Record - }) { - return ( - <> -
- - removals - -
- {value && ( - -
- {Object.entries(value).map(([contractId, reason]) => ( -
- - - - {reason} - - -
- ))} -
-
- )} - - ) - }, - }, - error: { - render: ({ value }: { value: string }) => ( -
- - - - - error - - - - {value} - - - -
- ), - }, - hint: { - render: ({ value }: { value: string }) => ( -
- {value} -
- ), - }, - allowance: { - render: ({ value }: { value: string }) => ( -
- - allowance - - -
- ), - }, - balance: { - render: ({ value }: { value: string }) => ( -
- - balance - - -
- ), - }, - address: { - render: ({ value }: { value: string }) => ( -
- - address - - -
- ), - }, - account: { - render: ({ value }: { value: string }) => ( -
- - account - - -
- ), - }, -} diff --git a/apps/renterd/pages/alerts/index.tsx b/apps/renterd/pages/alerts/index.tsx new file mode 100644 index 000000000..aa65cb8d1 --- /dev/null +++ b/apps/renterd/pages/alerts/index.tsx @@ -0,0 +1,5 @@ +import { Alerts } from '../../components/Alerts' + +export default function AlertsPage() { + return +} diff --git a/libs/design-system/src/components/Table/TableRow.tsx b/libs/design-system/src/components/Table/TableRow.tsx index 2ff5775f5..ff2d8cb14 100644 --- a/libs/design-system/src/components/Table/TableRow.tsx +++ b/libs/design-system/src/components/Table/TableRow.tsx @@ -35,7 +35,7 @@ type Props = { data: Data context?: Context columns: TableColumn[] - rowSize?: 'dense' | 'default' + rowSize?: 'dense' | 'default' | 'auto' focusId?: string focusColor?: 'green' | 'red' | 'amber' | 'blue' | 'default' getCellClassNames: ( @@ -135,7 +135,11 @@ export function createTableRow<
diff --git a/libs/design-system/src/components/Table/index.tsx b/libs/design-system/src/components/Table/index.tsx index 7cf496dfa..3695ba247 100644 --- a/libs/design-system/src/components/Table/index.tsx +++ b/libs/design-system/src/components/Table/index.tsx @@ -62,7 +62,7 @@ type Props< sortDirection?: 'asc' | 'desc' toggleSort?: (field: SortField) => void sortableColumns?: SortField[] - rowSize?: 'dense' | 'default' + rowSize?: 'dense' | 'default' | 'auto' pageSize: number isLoading: boolean emptyState?: React.ReactNode @@ -319,7 +319,11 @@ export function Table<
diff --git a/libs/design-system/src/components/ValueCopyable.tsx b/libs/design-system/src/components/ValueCopyable.tsx index f58f7a8fa..fc13813f5 100644 --- a/libs/design-system/src/components/ValueCopyable.tsx +++ b/libs/design-system/src/components/ValueCopyable.tsx @@ -34,6 +34,7 @@ type Props = { color?: React.ComponentProps['color'] className?: string siascanUrl?: string + contextMenu?: React.ReactNode } export function ValueCopyable({ @@ -50,6 +51,7 @@ export function ValueCopyable({ color = 'contrast', className, siascanUrl, + contextMenu, }: Props) { const label = customLabel || getEntityTypeCopyLabel(type) const maxLength = customMaxLength || getEntityDisplayLength(type) @@ -88,13 +90,15 @@ export function ValueCopyable({ )}
- + {contextMenu || ( + + )}
) diff --git a/libs/design-system/src/core/Panel.tsx b/libs/design-system/src/core/Panel.tsx index 6514c36f6..f18baa87b 100644 --- a/libs/design-system/src/core/Panel.tsx +++ b/libs/design-system/src/core/Panel.tsx @@ -1,18 +1,34 @@ import { cva, VariantProps } from 'class-variance-authority' import React from 'react' -export const panelStyles = cva([ - 'bg-white dark:bg-graydark-200', - 'transition-shadow ease-in-out duration-300', - 'shadow-sm hover:shadow', - 'rounded', - 'border', - 'border-gray-400 dark:border-graydark-400', -]) +export const panelStyles = cva( + [ + 'transition-shadow ease-in-out duration-300', + 'shadow-sm hover:shadow', + 'rounded', + 'border', + ], + { + variants: { + color: { + default: [ + 'bg-white dark:bg-graydark-200', + 'border-gray-400 dark:border-graydark-400', + ], + subtle: ['border-gray-200 dark:border-graydark-200'], + }, + }, + defaultVariants: { + color: 'default', + }, + } +) export const Panel = React.forwardRef< HTMLDivElement, VariantProps & React.HTMLAttributes ->(({ className, ...props }, ref) => { - return
+>(({ className, color, ...props }, ref) => { + return ( +
+ ) }) diff --git a/libs/react-renterd/src/bus.ts b/libs/react-renterd/src/bus.ts index 2e0046fbc..2cc83223e 100644 --- a/libs/react-renterd/src/bus.ts +++ b/libs/react-renterd/src/bus.ts @@ -719,8 +719,21 @@ export type Alert = { } } +export type AlertsParams = { + limit: number + offset: number + severity?: AlertSeverity +} + +export type AlertsResponse = { + alerts?: Alert[] + hasMore: boolean + totals: Record +} + const alertsRoute = '/bus/alerts' -export function useAlerts(args?: HookArgsSwr) { +// params are required because omitting them returns a deprecated response structure +export function useAlerts(args: HookArgsSwr) { return useGetSwr({ ...args, route: alertsRoute }) }