foreman_remote_execution 4.5.1 → 4.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.github/workflows/ruby_ci.yml +7 -0
- data/app/controllers/ui_job_wizard_controller.rb +7 -0
- data/app/graphql/types/job_invocation.rb +16 -0
- data/app/helpers/concerns/foreman_remote_execution/hosts_helper_extensions.rb +5 -1
- data/app/helpers/remote_execution_helper.rb +9 -3
- data/app/lib/actions/remote_execution/run_hosts_job.rb +1 -1
- data/app/models/job_invocation_composer.rb +3 -3
- data/app/models/job_template.rb +1 -1
- data/app/models/remote_execution_feature.rb +5 -1
- data/app/models/remote_execution_provider.rb +1 -1
- data/app/views/templates/ssh/module_action.erb +1 -0
- data/app/views/templates/ssh/power_action.erb +2 -0
- data/app/views/templates/ssh/puppet_run_once.erb +1 -0
- data/foreman_remote_execution.gemspec +2 -4
- data/lib/foreman_remote_execution/engine.rb +3 -0
- data/lib/foreman_remote_execution/version.rb +1 -1
- data/test/graphql/queries/job_invocation_query_test.rb +31 -0
- data/test/graphql/queries/job_invocations_query_test.rb +35 -0
- data/test/unit/concerns/host_extensions_test.rb +4 -4
- data/test/unit/input_template_renderer_test.rb +1 -89
- data/test/unit/job_invocation_composer_test.rb +1 -12
- data/webpack/JobWizard/JobWizard.js +28 -8
- data/webpack/JobWizard/JobWizard.scss +39 -0
- data/webpack/JobWizard/JobWizardConstants.js +10 -0
- data/webpack/JobWizard/JobWizardSelectors.js +9 -0
- data/webpack/JobWizard/__tests__/fixtures.js +104 -2
- data/webpack/JobWizard/__tests__/integration.test.js +13 -85
- data/webpack/JobWizard/steps/AdvancedFields/AdvancedFields.js +21 -4
- data/webpack/JobWizard/steps/AdvancedFields/DescriptionField.js +67 -0
- data/webpack/JobWizard/steps/AdvancedFields/Fields.js +73 -59
- data/webpack/JobWizard/steps/AdvancedFields/__tests__/AdvancedFields.test.js +135 -16
- data/webpack/JobWizard/steps/AdvancedFields/__tests__/DescriptionField.test.js +23 -0
- data/webpack/JobWizard/steps/CategoryAndTemplate/CategoryAndTemplate.test.js +122 -51
- data/webpack/JobWizard/steps/Schedule/QueryType.js +48 -0
- data/webpack/JobWizard/steps/Schedule/RepeatOn.js +61 -0
- data/webpack/JobWizard/steps/Schedule/ScheduleType.js +25 -0
- data/webpack/JobWizard/steps/Schedule/StartEndDates.js +51 -0
- data/webpack/JobWizard/steps/Schedule/__tests__/StartEndDates.test.js +22 -0
- data/webpack/JobWizard/steps/Schedule/index.js +41 -0
- data/webpack/JobWizard/steps/form/FormHelpers.js +1 -0
- data/webpack/JobWizard/steps/form/Formatter.js +149 -0
- data/webpack/JobWizard/steps/form/NumberInput.js +33 -0
- data/webpack/JobWizard/steps/form/SelectField.js +14 -2
- data/webpack/JobWizard/steps/form/__tests__/Formatter.test.js.example +76 -0
- data/webpack/__mocks__/foremanReact/components/SearchBar.js +18 -1
- data/webpack/react_app/components/RecentJobsCard/JobStatusIcon.js +43 -0
- data/webpack/react_app/components/RecentJobsCard/RecentJobsCard.js +72 -66
- data/webpack/react_app/components/RecentJobsCard/RecentJobsTable.js +98 -0
- data/webpack/react_app/components/RecentJobsCard/constants.js +11 -0
- data/webpack/react_app/components/RecentJobsCard/styles.scss +11 -0
- data/webpack/react_app/components/TargetingHosts/__tests__/__snapshots__/TargetingHostsPage.test.js.snap +1 -0
- data/webpack/react_app/extend/fillRecentJobsCard.js +1 -1
- metadata +23 -27
- data/webpack/JobWizard/__tests__/JobWizard.test.js +0 -13
- data/webpack/JobWizard/__tests__/__snapshots__/JobWizard.test.js.snap +0 -32
- data/webpack/JobWizard/steps/AdvancedFields/__tests__/__snapshots__/AdvancedFields.test.js.snap +0 -249
- data/webpack/JobWizard/steps/CategoryAndTemplate/__snapshots__/CategoryAndTemplate.test.js.snap +0 -113
- data/webpack/JobWizard/steps/form/__tests__/GroupedSelectField.test.js +0 -38
- data/webpack/JobWizard/steps/form/__tests__/SelectField.test.js +0 -23
- data/webpack/JobWizard/steps/form/__tests__/__snapshots__/GroupedSelectField.test.js.snap +0 -37
- data/webpack/JobWizard/steps/form/__tests__/__snapshots__/SelectField.test.js.snap +0 -23
- data/webpack/react_app/components/RecentJobsCard/styles.css +0 -15
@@ -8,6 +8,8 @@ export const SelectField = ({
|
|
8
8
|
options,
|
9
9
|
value,
|
10
10
|
setValue,
|
11
|
+
labelIcon,
|
12
|
+
isRequired,
|
11
13
|
...props
|
12
14
|
}) => {
|
13
15
|
const onSelect = (event, selection) => {
|
@@ -16,7 +18,12 @@ export const SelectField = ({
|
|
16
18
|
};
|
17
19
|
const [isOpen, setIsOpen] = useState(false);
|
18
20
|
return (
|
19
|
-
<FormGroup
|
21
|
+
<FormGroup
|
22
|
+
label={label}
|
23
|
+
fieldId={fieldId}
|
24
|
+
labelIcon={labelIcon}
|
25
|
+
isRequired={isRequired}
|
26
|
+
>
|
20
27
|
<Select
|
21
28
|
selections={value}
|
22
29
|
onSelect={onSelect}
|
@@ -35,14 +42,19 @@ export const SelectField = ({
|
|
35
42
|
);
|
36
43
|
};
|
37
44
|
SelectField.propTypes = {
|
38
|
-
label: PropTypes.string
|
45
|
+
label: PropTypes.string,
|
39
46
|
fieldId: PropTypes.string.isRequired,
|
40
47
|
options: PropTypes.array,
|
41
48
|
value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
|
42
49
|
setValue: PropTypes.func.isRequired,
|
50
|
+
labelIcon: PropTypes.node,
|
51
|
+
isRequired: PropTypes.bool,
|
43
52
|
};
|
44
53
|
|
45
54
|
SelectField.defaultProps = {
|
55
|
+
label: null,
|
46
56
|
options: [],
|
57
|
+
labelIcon: null,
|
47
58
|
value: null,
|
59
|
+
isRequired: false,
|
48
60
|
};
|
@@ -0,0 +1,76 @@
|
|
1
|
+
import React from 'react';
|
2
|
+
import { Provider } from 'react-redux';
|
3
|
+
import configureMockStore from 'redux-mock-store';
|
4
|
+
import * as patternfly from '@patternfly/react-core';
|
5
|
+
import { mount, shallow } from '@theforeman/test';
|
6
|
+
import { formatter } from '../Formatter';
|
7
|
+
|
8
|
+
jest.spyOn(patternfly, 'Select');
|
9
|
+
jest.spyOn(patternfly, 'SelectOption');
|
10
|
+
jest.spyOn(patternfly, 'FormGroup');
|
11
|
+
patternfly.Select.mockImplementation(props => <div props={props} />);
|
12
|
+
patternfly.SelectOption.mockImplementation(props => <div props={props} />);
|
13
|
+
patternfly.FormGroup.mockImplementation(props => <div props={props} />);
|
14
|
+
const mockStore = configureMockStore([]);
|
15
|
+
const store = mockStore({});
|
16
|
+
|
17
|
+
describe('formatter', () => {
|
18
|
+
it('render date input', () => {
|
19
|
+
const props = {
|
20
|
+
name: 'date adv',
|
21
|
+
required: false,
|
22
|
+
options: '',
|
23
|
+
advanced: true,
|
24
|
+
value_type: 'date',
|
25
|
+
resource_type: 'ansible_roles',
|
26
|
+
default: '',
|
27
|
+
hidden_value: false,
|
28
|
+
};
|
29
|
+
expect(shallow(formatter(props, {}, jest.fn()))).toMatchSnapshot();
|
30
|
+
});
|
31
|
+
it('render text input', () => {
|
32
|
+
const props = {
|
33
|
+
name: 'plain adv hidden',
|
34
|
+
required: true,
|
35
|
+
description: 'some Description',
|
36
|
+
options: '',
|
37
|
+
advanced: true,
|
38
|
+
value_type: 'plain',
|
39
|
+
resource_type: 'ansible_roles',
|
40
|
+
default: 'Default val',
|
41
|
+
hidden_value: true,
|
42
|
+
};
|
43
|
+
expect(shallow(formatter(props, {}, jest.fn()))).toMatchSnapshot();
|
44
|
+
});
|
45
|
+
it('render select input', () => {
|
46
|
+
const props = {
|
47
|
+
name: 'adv plain search',
|
48
|
+
required: false,
|
49
|
+
input_type: 'user',
|
50
|
+
options: 'option 1\r\noption 2\r\noption 3\r\noption 4',
|
51
|
+
advanced: true,
|
52
|
+
value_type: 'plain',
|
53
|
+
resource_type: 'ansible_roles',
|
54
|
+
default: '',
|
55
|
+
hidden_value: false,
|
56
|
+
};
|
57
|
+
expect(shallow(formatter(props, {}, jest.fn()))).toMatchSnapshot();
|
58
|
+
});
|
59
|
+
it('render search input', () => {
|
60
|
+
const props = {
|
61
|
+
name: 'search adv',
|
62
|
+
required: false,
|
63
|
+
options: '',
|
64
|
+
advanced: true,
|
65
|
+
value_type: 'search',
|
66
|
+
resource_type: 'foreman_tasks/tasks',
|
67
|
+
default: '',
|
68
|
+
hidden_value: false,
|
69
|
+
};
|
70
|
+
expect(
|
71
|
+
mount(
|
72
|
+
<Provider store={store}>{formatter(props, {}, jest.fn())}</Provider>
|
73
|
+
)
|
74
|
+
).toMatchSnapshot();
|
75
|
+
});
|
76
|
+
});
|
@@ -1,2 +1,19 @@
|
|
1
|
-
|
1
|
+
import React from 'react';
|
2
|
+
import PropTypes from 'prop-types';
|
3
|
+
|
4
|
+
const SearchBar = ({ onChange }) => (
|
5
|
+
<input
|
6
|
+
className="foreman-search"
|
7
|
+
onChange={onChange}
|
8
|
+
placeholder="Filter..."
|
9
|
+
/>
|
10
|
+
);
|
2
11
|
export default SearchBar;
|
12
|
+
|
13
|
+
SearchBar.propTypes = {
|
14
|
+
onChange: PropTypes.func,
|
15
|
+
};
|
16
|
+
|
17
|
+
SearchBar.defaultProps = {
|
18
|
+
onChange: () => null,
|
19
|
+
};
|
@@ -0,0 +1,43 @@
|
|
1
|
+
import React from 'react';
|
2
|
+
import PropTypes from 'prop-types';
|
3
|
+
import {
|
4
|
+
CheckCircleIcon,
|
5
|
+
ExclamationCircleIcon,
|
6
|
+
QuestionCircleIcon,
|
7
|
+
} from '@patternfly/react-icons';
|
8
|
+
import { JOB_SUCCESS_STATUS, JOB_ERROR_STATUS } from './constants';
|
9
|
+
import './styles.scss';
|
10
|
+
|
11
|
+
const JobStatusIcon = ({ status, children, ...props }) => {
|
12
|
+
switch (status) {
|
13
|
+
case JOB_SUCCESS_STATUS:
|
14
|
+
return (
|
15
|
+
<span className="job-success">
|
16
|
+
<CheckCircleIcon {...props} /> {children}
|
17
|
+
</span>
|
18
|
+
);
|
19
|
+
case JOB_ERROR_STATUS:
|
20
|
+
return (
|
21
|
+
<span className="job-error">
|
22
|
+
<ExclamationCircleIcon {...props} /> {children}
|
23
|
+
</span>
|
24
|
+
);
|
25
|
+
default:
|
26
|
+
return (
|
27
|
+
<span className="job-info">
|
28
|
+
<QuestionCircleIcon {...props} /> {children}
|
29
|
+
</span>
|
30
|
+
);
|
31
|
+
}
|
32
|
+
};
|
33
|
+
|
34
|
+
JobStatusIcon.propTypes = {
|
35
|
+
status: PropTypes.number,
|
36
|
+
children: PropTypes.string.isRequired,
|
37
|
+
};
|
38
|
+
|
39
|
+
JobStatusIcon.defaultProps = {
|
40
|
+
status: undefined,
|
41
|
+
};
|
42
|
+
|
43
|
+
export default JobStatusIcon;
|
@@ -1,76 +1,77 @@
|
|
1
|
-
/* eslint-disable camelcase */
|
2
|
-
|
3
1
|
import PropTypes from 'prop-types';
|
4
|
-
import React from 'react';
|
5
|
-
import Skeleton from 'react-loading-skeleton';
|
6
|
-
import ElipsisWithTooltip from 'react-ellipsis-with-tooltip';
|
7
|
-
|
8
|
-
import { Grid, GridItem } from '@patternfly/react-core';
|
9
|
-
import {
|
10
|
-
PropertiesSidePanel,
|
11
|
-
PropertyItem,
|
12
|
-
} from '@patternfly/react-catalog-view-extension';
|
13
|
-
import { ArrowIcon, ErrorCircleOIcon, OkIcon } from '@patternfly/react-icons';
|
2
|
+
import React, { useState } from 'react';
|
14
3
|
|
15
|
-
import {
|
16
|
-
import
|
17
|
-
import RelativeDateTime from 'foremanReact/components/common/dates/RelativeDateTime';
|
4
|
+
import { DropdownItem, Tabs, Tab, TabTitleText } from '@patternfly/react-core';
|
5
|
+
import CardTemplate from 'foremanReact/components/HostDetails/Templates/CardItem/CardTemplate';
|
18
6
|
import { translate as __ } from 'foremanReact/common/I18n';
|
19
|
-
import '
|
7
|
+
import { foremanUrl } from 'foremanReact/common/helpers';
|
20
8
|
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
|
25
|
-
|
26
|
-
|
9
|
+
import {
|
10
|
+
FINISHED_TAB,
|
11
|
+
RUNNING_TAB,
|
12
|
+
SCHEDULED_TAB,
|
13
|
+
JOB_BASE_URL,
|
14
|
+
} from './constants';
|
15
|
+
import RecentJobsTable from './RecentJobsTable';
|
27
16
|
|
28
|
-
|
29
|
-
|
30
|
-
|
31
|
-
|
17
|
+
const RecentJobsCard = ({ hostDetails: { name, id } }) => {
|
18
|
+
const [activeTab, setActiveTab] = useState(FINISHED_TAB);
|
19
|
+
|
20
|
+
const handleTabClick = (evt, tabIndex) => setActiveTab(tabIndex);
|
32
21
|
|
33
22
|
return (
|
34
|
-
<
|
35
|
-
header={
|
36
|
-
|
37
|
-
|
38
|
-
|
39
|
-
|
40
|
-
|
41
|
-
|
42
|
-
|
23
|
+
<CardTemplate
|
24
|
+
header={__('Recent Jobs')}
|
25
|
+
dropdownItems={[
|
26
|
+
<DropdownItem
|
27
|
+
href={foremanUrl(`${JOB_BASE_URL}${name}`)}
|
28
|
+
key="link-to-all"
|
29
|
+
>
|
30
|
+
{__('View All Jobs')}
|
31
|
+
</DropdownItem>,
|
32
|
+
<DropdownItem
|
33
|
+
href={foremanUrl(
|
34
|
+
`${JOB_BASE_URL}${name}+and+status+%3D+failed+or+status%3D+succeeded`
|
35
|
+
)}
|
36
|
+
key="link-to-finished"
|
37
|
+
>
|
38
|
+
{__('View Finished Jobs')}
|
39
|
+
</DropdownItem>,
|
40
|
+
<DropdownItem
|
41
|
+
href={foremanUrl(`${JOB_BASE_URL}${name}+and+status+%3D+running`)}
|
42
|
+
key="link-to-running"
|
43
|
+
>
|
44
|
+
{__('View Running Jobs')}
|
45
|
+
</DropdownItem>,
|
46
|
+
<DropdownItem
|
47
|
+
href={foremanUrl(`${JOB_BASE_URL}${name}+and+status+%3D+queued`)}
|
48
|
+
key="link-to-scheduled"
|
49
|
+
>
|
50
|
+
{__('View Scheduled Jobs')}
|
51
|
+
</DropdownItem>,
|
52
|
+
]}
|
43
53
|
>
|
44
|
-
<
|
45
|
-
|
46
|
-
|
47
|
-
|
48
|
-
|
49
|
-
|
50
|
-
|
51
|
-
|
52
|
-
|
53
|
-
|
54
|
-
|
55
|
-
|
56
|
-
|
57
|
-
|
58
|
-
|
59
|
-
|
60
|
-
|
61
|
-
|
62
|
-
|
63
|
-
|
64
|
-
|
65
|
-
</a>
|
66
|
-
) : (
|
67
|
-
<Skeleton />
|
68
|
-
)
|
69
|
-
}
|
70
|
-
/>
|
71
|
-
))}
|
72
|
-
</PropertiesSidePanel>
|
73
|
-
</CardItem>
|
54
|
+
<Tabs mountOnEnter activeKey={activeTab} onSelect={handleTabClick}>
|
55
|
+
<Tab
|
56
|
+
eventKey={FINISHED_TAB}
|
57
|
+
title={<TabTitleText>{__('Finished')}</TabTitleText>}
|
58
|
+
>
|
59
|
+
<RecentJobsTable hostId={id} status="failed+or+status%3D+succeeded" />
|
60
|
+
</Tab>
|
61
|
+
<Tab
|
62
|
+
eventKey={RUNNING_TAB}
|
63
|
+
title={<TabTitleText>{__('Running')}</TabTitleText>}
|
64
|
+
>
|
65
|
+
<RecentJobsTable hostId={id} status="running" />
|
66
|
+
</Tab>
|
67
|
+
<Tab
|
68
|
+
eventKey={SCHEDULED_TAB}
|
69
|
+
title={<TabTitleText>{__('Scheduled')}</TabTitleText>}
|
70
|
+
>
|
71
|
+
<RecentJobsTable hostId={id} status="queued" />
|
72
|
+
</Tab>
|
73
|
+
</Tabs>
|
74
|
+
</CardTemplate>
|
74
75
|
);
|
75
76
|
};
|
76
77
|
|
@@ -79,5 +80,10 @@ export default RecentJobsCard;
|
|
79
80
|
RecentJobsCard.propTypes = {
|
80
81
|
hostDetails: PropTypes.shape({
|
81
82
|
name: PropTypes.string,
|
82
|
-
|
83
|
+
id: PropTypes.number,
|
84
|
+
}),
|
85
|
+
};
|
86
|
+
|
87
|
+
RecentJobsCard.defaultProps = {
|
88
|
+
hostDetails: {},
|
83
89
|
};
|
@@ -0,0 +1,98 @@
|
|
1
|
+
import PropTypes from 'prop-types';
|
2
|
+
import React from 'react';
|
3
|
+
import {
|
4
|
+
DataList,
|
5
|
+
DataListItem,
|
6
|
+
DataListItemRow,
|
7
|
+
DataListItemCells,
|
8
|
+
DataListCell,
|
9
|
+
DataListWrapModifier,
|
10
|
+
Text,
|
11
|
+
Bullseye,
|
12
|
+
} from '@patternfly/react-core';
|
13
|
+
import { STATUS } from 'foremanReact/constants';
|
14
|
+
|
15
|
+
import RelativeDateTime from 'foremanReact/components/common/dates/RelativeDateTime';
|
16
|
+
import { useAPI } from 'foremanReact/common/hooks/API/APIHooks';
|
17
|
+
import SkeletonLoader from 'foremanReact/components/common/SkeletonLoader';
|
18
|
+
import { translate as __ } from 'foremanReact/common/I18n';
|
19
|
+
import { foremanUrl } from 'foremanReact/common/helpers';
|
20
|
+
|
21
|
+
import JobStatusIcon from './JobStatusIcon';
|
22
|
+
import { JOB_API_URL, JOBS_IN_CARD } from './constants';
|
23
|
+
|
24
|
+
const RecentJobsTable = ({ status, hostId }) => {
|
25
|
+
const jobsUrl =
|
26
|
+
hostId &&
|
27
|
+
foremanUrl(
|
28
|
+
`${JOB_API_URL}${hostId}+and+status%3D${status}&per_page=${JOBS_IN_CARD}`
|
29
|
+
);
|
30
|
+
const {
|
31
|
+
response: { results: jobs },
|
32
|
+
status: responseStatus,
|
33
|
+
} = useAPI('get', jobsUrl);
|
34
|
+
|
35
|
+
return (
|
36
|
+
<DataList aria-label="recent-jobs-table" isCompact>
|
37
|
+
<SkeletonLoader
|
38
|
+
skeletonProps={{ count: 3 }}
|
39
|
+
status={responseStatus || STATUS.PENDING}
|
40
|
+
emptyState={
|
41
|
+
<Bullseye>
|
42
|
+
<Text style={{ marginTop: '20px' }} component="p">
|
43
|
+
{__('No results found')}
|
44
|
+
</Text>
|
45
|
+
</Bullseye>
|
46
|
+
}
|
47
|
+
>
|
48
|
+
{jobs?.length &&
|
49
|
+
jobs.map(
|
50
|
+
({
|
51
|
+
status: jobStatus,
|
52
|
+
status_label: label,
|
53
|
+
id,
|
54
|
+
start_at: startAt,
|
55
|
+
description,
|
56
|
+
}) => (
|
57
|
+
<DataListItem key={id}>
|
58
|
+
<DataListItemRow>
|
59
|
+
<DataListItemCells
|
60
|
+
dataListCells={[
|
61
|
+
<DataListCell
|
62
|
+
wrapModifier={DataListWrapModifier.truncate}
|
63
|
+
key={`name-${id}`}
|
64
|
+
>
|
65
|
+
<a href={foremanUrl(`/job_invocations/${id}`)}>
|
66
|
+
{description}
|
67
|
+
</a>
|
68
|
+
</DataListCell>,
|
69
|
+
<DataListCell key={`date-${id}`}>
|
70
|
+
<RelativeDateTime date={startAt} />
|
71
|
+
</DataListCell>,
|
72
|
+
<DataListCell key={`status-${id}`}>
|
73
|
+
<JobStatusIcon status={jobStatus}>
|
74
|
+
{label}
|
75
|
+
</JobStatusIcon>
|
76
|
+
</DataListCell>,
|
77
|
+
]}
|
78
|
+
/>
|
79
|
+
</DataListItemRow>
|
80
|
+
</DataListItem>
|
81
|
+
)
|
82
|
+
)}
|
83
|
+
</SkeletonLoader>
|
84
|
+
</DataList>
|
85
|
+
);
|
86
|
+
};
|
87
|
+
|
88
|
+
RecentJobsTable.propTypes = {
|
89
|
+
hostId: PropTypes.number,
|
90
|
+
status: PropTypes.string,
|
91
|
+
};
|
92
|
+
|
93
|
+
RecentJobsTable.defaultProps = {
|
94
|
+
hostId: undefined,
|
95
|
+
status: STATUS.PENDING,
|
96
|
+
};
|
97
|
+
|
98
|
+
export default RecentJobsTable;
|
@@ -1 +1,12 @@
|
|
1
1
|
export const HOST_DETAILS_JOBS = 'HOST_DETAILS_JOBS';
|
2
|
+
export const FINISHED_TAB = 0;
|
3
|
+
export const RUNNING_TAB = 1;
|
4
|
+
export const SCHEDULED_TAB = 2;
|
5
|
+
|
6
|
+
export const JOB_SUCCESS_STATUS = 0;
|
7
|
+
export const JOB_ERROR_STATUS = 1;
|
8
|
+
|
9
|
+
export const JOB_BASE_URL = '/job_invocations?search=host+%3D+';
|
10
|
+
export const JOB_API_URL =
|
11
|
+
'/api/job_invocations?order=start_at+DESC&search=targeted_host_id%3D';
|
12
|
+
export const JOBS_IN_CARD = 3;
|