diff --git a/packages/api-v4/.changeset/pr-11346-changed-1733145182722.md b/packages/api-v4/.changeset/pr-11346-changed-1733145182722.md new file mode 100644 index 00000000000..7f6129d47ae --- /dev/null +++ b/packages/api-v4/.changeset/pr-11346-changed-1733145182722.md @@ -0,0 +1,5 @@ +--- +"@linode/api-v4": Changed +--- + +Type of `AlertDefinitionType` to `'system'|'user'` ([#11346](https://github.com/linode/manager/pull/11346)) diff --git a/packages/api-v4/src/cloudpulse/alerts.ts b/packages/api-v4/src/cloudpulse/alerts.ts index 3c6f909b9db..586be5fbd20 100644 --- a/packages/api-v4/src/cloudpulse/alerts.ts +++ b/packages/api-v4/src/cloudpulse/alerts.ts @@ -1,7 +1,14 @@ import { createAlertDefinitionSchema } from '@linode/validation'; -import Request, { setURL, setMethod, setData } from '../request'; +import Request, { + setURL, + setMethod, + setData, + setParams, + setXFilter, +} from '../request'; import { Alert, AlertServiceType, CreateAlertDefinitionPayload } from './types'; -import { BETA_API_ROOT as API_ROOT } from 'src/constants'; +import { BETA_API_ROOT as API_ROOT } from '../constants'; +import { Params, Filter, ResourcePage } from '../types'; export const createAlertDefinition = ( data: CreateAlertDefinitionPayload, @@ -16,3 +23,11 @@ export const createAlertDefinition = ( setMethod('POST'), setData(data, createAlertDefinitionSchema) ); + +export const getAlertDefinitions = (params?: Params, filters?: Filter) => + Request>( + setURL(`${API_ROOT}/monitor/alert-definitions`), + setMethod('GET'), + setParams(params), + setXFilter(filters) + ); diff --git a/packages/api-v4/src/cloudpulse/types.ts b/packages/api-v4/src/cloudpulse/types.ts index 765a1d71ba1..465220792b4 100644 --- a/packages/api-v4/src/cloudpulse/types.ts +++ b/packages/api-v4/src/cloudpulse/types.ts @@ -3,7 +3,7 @@ export type MetricAggregationType = 'avg' | 'sum' | 'min' | 'max' | 'count'; export type MetricOperatorType = 'eq' | 'gt' | 'lt' | 'gte' | 'lte'; export type AlertServiceType = 'linode' | 'dbaas'; type DimensionFilterOperatorType = 'eq' | 'neq' | 'startswith' | 'endswith'; -export type AlertDefinitionType = 'default' | 'custom'; +export type AlertDefinitionType = 'system' | 'user'; export type AlertStatusType = 'enabled' | 'disabled'; export interface Dashboard { id: number; diff --git a/packages/manager/.changeset/pr-11346-added-1733145106911.md b/packages/manager/.changeset/pr-11346-added-1733145106911.md new file mode 100644 index 00000000000..6b4d5327cef --- /dev/null +++ b/packages/manager/.changeset/pr-11346-added-1733145106911.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Added +--- + +AlertListing component and AlertTableRow component with Unit Tests ([#11346](https://github.com/linode/manager/pull/11346)) diff --git a/packages/manager/src/factories/cloudpulse/alerts.ts b/packages/manager/src/factories/cloudpulse/alerts.ts index a0bc2b6edf7..4a7bb721854 100644 --- a/packages/manager/src/factories/cloudpulse/alerts.ts +++ b/packages/manager/src/factories/cloudpulse/alerts.ts @@ -22,7 +22,7 @@ export const alertFactory = Factory.Sync.makeFactory({ polling_interval_seconds: 0, trigger_occurrences: 0, }, - type: 'default', + type: 'user', updated: new Date().toISOString(), updated_by: 'user1', }); diff --git a/packages/manager/src/features/CloudPulse/Alerts/AlertsLanding/AlertsDefinitionLanding.tsx b/packages/manager/src/features/CloudPulse/Alerts/AlertsLanding/AlertsDefinitionLanding.tsx index 352ded4e3e2..0f037732ece 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/AlertsLanding/AlertsDefinitionLanding.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/AlertsLanding/AlertsDefinitionLanding.tsx @@ -1,29 +1,21 @@ -import { Paper, Typography } from '@linode/ui'; import * as React from 'react'; import { Route, Switch } from 'react-router-dom'; +import { AlertListing } from '../AlertsListing/AlertListing'; import { CreateAlertDefinition } from '../CreateAlert/CreateAlertDefinition'; export const AlertDefinitionLanding = () => { return ( } + component={CreateAlertDefinition} path="/monitor/alerts/definitions/create" /> ); }; - -const AlertDefinition = () => { - return ( - - Alert Definition - - ); -}; diff --git a/packages/manager/src/features/CloudPulse/Alerts/AlertsListing/AlertActionMenu.tsx b/packages/manager/src/features/CloudPulse/Alerts/AlertsListing/AlertActionMenu.tsx new file mode 100644 index 00000000000..8e379ba3a08 --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Alerts/AlertsListing/AlertActionMenu.tsx @@ -0,0 +1,36 @@ +import * as React from 'react'; + +import { ActionMenu } from 'src/components/ActionMenu/ActionMenu'; + +import type { AlertDefinitionType } from '@linode/api-v4'; + +export interface ActionHandlers { + // These handlers will be enhanced based on the alert type and actions required + /** + * Callback for delete action + */ + handleDelete: () => void; + + /** + * Callback for show details action + */ + handleDetails: () => void; +} + +export interface AlertActionMenuProps { + /** + * Type of the alert + */ + alertType?: AlertDefinitionType; + /** + * Handlers for alert actions like delete, show details etc., + */ + handlers?: ActionHandlers; +} + +/** + * The handlers and alertType are made optional only temporarily, they will be enabled but they are dependent on another feature which will be part of next PR + */ +export const AlertActionMenu = () => { + return ; +}; diff --git a/packages/manager/src/features/CloudPulse/Alerts/AlertsListing/AlertListing.test.tsx b/packages/manager/src/features/CloudPulse/Alerts/AlertsListing/AlertListing.test.tsx new file mode 100644 index 00000000000..e0e2bf18e41 --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Alerts/AlertsListing/AlertListing.test.tsx @@ -0,0 +1,62 @@ +import * as React from 'react'; + +import { alertFactory } from 'src/factories/cloudpulse/alerts'; +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { AlertListing } from './AlertListing'; + +const queryMocks = vi.hoisted(() => ({ + useAllAlertDefinitionsQuery: vi.fn().mockReturnValue({}), +})); + +vi.mock('src/queries/cloudpulse/alerts', async () => { + const actual = await vi.importActual('src/queries/cloudpulse/alerts'); + return { + ...actual, + useAllAlertDefinitionsQuery: queryMocks.useAllAlertDefinitionsQuery, + }; +}); + +const mockResponse = alertFactory.buildList(3); + +describe('Alert Listing', () => { + it('should render the error message', async () => { + queryMocks.useAllAlertDefinitionsQuery.mockReturnValue({ + data: undefined, + error: 'an error happened', + isError: true, + isLoading: false, + }); + const { getAllByText } = renderWithTheme(); + getAllByText('Error in fetching the alerts.'); + }); + + it('should render the alert landing table with items', async () => { + queryMocks.useAllAlertDefinitionsQuery.mockReturnValue({ + data: mockResponse, + isError: false, + isLoading: false, + status: 'success', + }); + const { getByText } = renderWithTheme(); + expect(getByText('Alert Name')).toBeInTheDocument(); + expect(getByText('Service')).toBeInTheDocument(); + expect(getByText('Status')).toBeInTheDocument(); + expect(getByText('Last Modified')).toBeInTheDocument(); + expect(getByText('Created By')).toBeInTheDocument(); + }); + + it('should render the alert row', async () => { + queryMocks.useAllAlertDefinitionsQuery.mockReturnValue({ + data: mockResponse, + isError: false, + isLoading: false, + status: 'success', + }); + + const { getByText } = renderWithTheme(); + expect(getByText(mockResponse[0].label)).toBeInTheDocument(); + expect(getByText(mockResponse[1].label)).toBeInTheDocument(); + expect(getByText(mockResponse[2].label)).toBeInTheDocument(); + }); +}); diff --git a/packages/manager/src/features/CloudPulse/Alerts/AlertsListing/AlertListing.tsx b/packages/manager/src/features/CloudPulse/Alerts/AlertsListing/AlertListing.tsx new file mode 100644 index 00000000000..f2684332333 --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Alerts/AlertsListing/AlertListing.tsx @@ -0,0 +1,72 @@ +import { Paper } from '@linode/ui'; +import { Grid } from '@mui/material'; +import React from 'react'; + +import { Table } from 'src/components/Table'; +import { TableBody } from 'src/components/TableBody'; +import { TableCell } from 'src/components/TableCell'; +import { TableHead } from 'src/components/TableHead'; +import { TableRow } from 'src/components/TableRow'; +import { TableRowError } from 'src/components/TableRowError/TableRowError'; +import { TableRowLoading } from 'src/components/TableRowLoading/TableRowLoading'; +import { TableSortCell } from 'src/components/TableSortCell'; +import { StyledPlaceholder } from 'src/features/StackScripts/StackScriptBase/StackScriptBase.styles'; +import { useAllAlertDefinitionsQuery } from 'src/queries/cloudpulse/alerts'; + +import { AlertTableRow } from './AlertTableRow'; +import { AlertListingTableLabelMap } from './constants'; + +export const AlertListing = () => { + // These are dummy order value and handleOrder methods, will replace them in the next PR + const order = 'asc'; + const handleOrderChange = () => { + return 'asc'; + }; + const { data: alerts, isError, isLoading } = useAllAlertDefinitionsQuery(); + if (alerts?.length === 0) { + return ( + + + + + + ); + } + return ( + + + + + {AlertListingTableLabelMap.map((value) => ( + + {value.colName} + + ))} + + + + + {isError && ( + + )} + {isLoading && } + {alerts?.map((alert) => ( + + ))} + +
+
+ ); +}; diff --git a/packages/manager/src/features/CloudPulse/Alerts/AlertsListing/AlertTableRow.test.tsx b/packages/manager/src/features/CloudPulse/Alerts/AlertsListing/AlertTableRow.test.tsx new file mode 100644 index 00000000000..232b12e801a --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Alerts/AlertsListing/AlertTableRow.test.tsx @@ -0,0 +1,29 @@ +import * as React from 'react'; + +import { alertFactory } from 'src/factories/cloudpulse/alerts'; +import { capitalize } from 'src/utilities/capitalize'; +import { renderWithTheme, wrapWithTableBody } from 'src/utilities/testHelpers'; + +import { AlertTableRow } from './AlertTableRow'; + +describe('Alert Row', () => { + it('should render an alert row', async () => { + const alert = alertFactory.build(); + const renderedAlert = ; + const { getByText } = renderWithTheme(wrapWithTableBody(renderedAlert)); + expect(getByText(alert.label)).toBeVisible(); + }); + + /** + * As of now the styling for the status 'enabled' is decided, in the future if they decide on the + other styles possible status values, will update them and test them accordingly. + */ + it('should render the status field in green color if status is enabled', () => { + const statusValue = 'enabled'; + const alert = alertFactory.build({ status: statusValue }); + const renderedAlert = ; + const { getByText } = renderWithTheme(wrapWithTableBody(renderedAlert)); + const statusElement = getByText(capitalize(statusValue)); + expect(getComputedStyle(statusElement).color).toBe('rgb(0, 176, 80)'); + }); +}); diff --git a/packages/manager/src/features/CloudPulse/Alerts/AlertsListing/AlertTableRow.tsx b/packages/manager/src/features/CloudPulse/Alerts/AlertsListing/AlertTableRow.tsx new file mode 100644 index 00000000000..ec6dac7b024 --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Alerts/AlertsListing/AlertTableRow.tsx @@ -0,0 +1,52 @@ +import { Typography } from '@linode/ui'; +import { useTheme } from '@mui/material'; +import * as React from 'react'; + +import { DateTimeDisplay } from 'src/components/DateTimeDisplay'; +import { TableCell } from 'src/components/TableCell'; +import { TableRow } from 'src/components/TableRow'; +import { capitalize } from 'src/utilities/capitalize'; + +import { AlertActionMenu } from './AlertActionMenu'; + +import type { Alert } from '@linode/api-v4'; + +interface Props { + /** + * alert details used by the component to fill the row details + */ + alert: Alert; +} + +export const AlertTableRow = (props: Props) => { + const { alert } = props; + const { created_by, id, label, service_type, status, updated } = alert; + const theme = useTheme(); + return ( + + {label} + {service_type} + + + {capitalize(status)} + + + + + + {created_by} + + {/* handlers are supposed to be passed to this AlertActionMenu, + it is dependent on other feature and will added as that feature in the next PR + */} + + + + ); +}; diff --git a/packages/manager/src/features/CloudPulse/Alerts/AlertsListing/constants.ts b/packages/manager/src/features/CloudPulse/Alerts/AlertsListing/constants.ts new file mode 100644 index 00000000000..057155cffb4 --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Alerts/AlertsListing/constants.ts @@ -0,0 +1,22 @@ +export const AlertListingTableLabelMap = [ + { + colName: 'Alert Name', + label: 'alertName', + }, + { + colName: 'Service', + label: 'service', + }, + { + colName: 'Status', + label: 'status', + }, + { + colName: 'Last Modified', + label: 'lastModified', + }, + { + colName: 'Created By', + label: 'createdBy', + }, +]; diff --git a/packages/manager/src/mocks/serverHandlers.ts b/packages/manager/src/mocks/serverHandlers.ts index f0df0bde8e2..88c33b8d5d8 100644 --- a/packages/manager/src/mocks/serverHandlers.ts +++ b/packages/manager/src/mocks/serverHandlers.ts @@ -2346,7 +2346,7 @@ export const handlers = [ http.post( '*/monitor/services/:service_type/alert-definitions', async ({ request }) => { - const types: AlertDefinitionType[] = ['custom', 'default']; + const types: AlertDefinitionType[] = ['system', 'user']; const status: AlertStatusType[] = ['enabled', 'disabled']; const severity: AlertSeverityType[] = [0, 1, 2, 3]; const users = ['user1', 'user2', 'user3']; @@ -2365,6 +2365,32 @@ export const handlers = [ return HttpResponse.json(response); } ), + http.get('*/monitor/alert-definitions', async ({ request }) => { + const customAlerts = alertFactory.buildList(2, { + severity: 0, + type: 'user', + }); + const customAlertsWithServiceType = alertFactory.buildList(2, { + service_type: 'dbaas', + severity: 1, + type: 'user', + }); + const defaultAlerts = alertFactory.buildList(1, { type: 'system' }); + const defaultAlertsWithServiceType = alertFactory.buildList(1, { + service_type: 'dbaas', + severity: 3, + type: 'system', + }); + const alerts = [ + ...defaultAlerts, + ...alertFactory.buildList(3, { status: 'disabled' }), + ...customAlerts, + ...defaultAlertsWithServiceType, + ...alertFactory.buildList(3), + ...customAlertsWithServiceType, + ]; + return HttpResponse.json(makeResourcePage(alerts)); + }), http.get('*/monitor/services', () => { const response: ServiceTypesList = { data: [ diff --git a/packages/manager/src/queries/cloudpulse/alerts.ts b/packages/manager/src/queries/cloudpulse/alerts.ts index 0da27a07093..a744f80d719 100644 --- a/packages/manager/src/queries/cloudpulse/alerts.ts +++ b/packages/manager/src/queries/cloudpulse/alerts.ts @@ -1,6 +1,12 @@ import { createAlertDefinition } from '@linode/api-v4/lib/cloudpulse'; -import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { + keepPreviousData, + useMutation, + useQuery, + useQueryClient, +} from '@tanstack/react-query'; +import { queryPresets } from '../base'; import { queryFactory } from './queries'; import type { @@ -8,7 +14,7 @@ import type { AlertServiceType, CreateAlertDefinitionPayload, } from '@linode/api-v4/lib/cloudpulse'; -import type { APIError } from '@linode/api-v4/lib/types'; +import type { APIError, Filter, Params } from '@linode/api-v4/lib/types'; export const useCreateAlertDefinition = (serviceType: AlertServiceType) => { const queryClient = useQueryClient(); @@ -19,3 +25,16 @@ export const useCreateAlertDefinition = (serviceType: AlertServiceType) => { }, }); }; + +export const useAllAlertDefinitionsQuery = ( + params?: Params, + filter?: Filter, + enabled: boolean = true +) => { + return useQuery({ + ...queryFactory.alerts._ctx.all(params, filter), + ...queryPresets.longLived, + enabled, + placeholderData: keepPreviousData, + }); +}; diff --git a/packages/manager/src/queries/cloudpulse/queries.ts b/packages/manager/src/queries/cloudpulse/queries.ts index dc9205f0ad4..41f280bf37d 100644 --- a/packages/manager/src/queries/cloudpulse/queries.ts +++ b/packages/manager/src/queries/cloudpulse/queries.ts @@ -11,6 +11,7 @@ import { databaseQueries } from '../databases/databases'; import { getAllLinodesRequest } from '../linodes/requests'; import { volumeQueries } from '../volumes/volumes'; import { fetchCloudPulseMetrics } from './metrics'; +import { getAllAlertsRequest } from './requests'; import type { CloudPulseMetricsRequest, @@ -23,7 +24,16 @@ const key = 'Clousepulse'; export const queryFactory = createQueryKeys(key, { alerts: { - // This query key is a placeholder , it will be updated once the relevant queries are added + contextQueries: { + alert: { + // This query key is a placeholder , it will be updated once the relevant queries are added + queryKey: null, + }, + all: (params: Params = {}, filter: Filter = {}) => ({ + queryFn: () => getAllAlertsRequest(params, filter), + queryKey: [params, filter], + }), + }, queryKey: null, }, dashboardById: (dashboardId: number) => ({ diff --git a/packages/manager/src/queries/cloudpulse/requests.ts b/packages/manager/src/queries/cloudpulse/requests.ts new file mode 100644 index 00000000000..148b9f0bdd4 --- /dev/null +++ b/packages/manager/src/queries/cloudpulse/requests.ts @@ -0,0 +1,16 @@ +import { getAlertDefinitions } from '@linode/api-v4'; + +import { getAll } from 'src/utilities/getAll'; + +import type { Alert, Filter, Params } from '@linode/api-v4'; + +export const getAllAlertsRequest = ( + passedParams: Params = {}, + passedFilter: Filter = {} +) => + getAll((params, filter) => + getAlertDefinitions( + { ...params, ...passedParams }, + { ...filter, ...passedFilter } + ) + )().then((data) => data.data);