diff --git a/awx/ui_next/src/api/models/Groups.js b/awx/ui_next/src/api/models/Groups.js index 019ba0ea94..4c19a44572 100644 --- a/awx/ui_next/src/api/models/Groups.js +++ b/awx/ui_next/src/api/models/Groups.js @@ -4,6 +4,12 @@ class Groups extends Base { constructor(http) { super(http); this.baseUrl = '/api/v2/groups/'; + + this.readAllHosts = this.readAllHosts.bind(this); + } + + readAllHosts(id, params) { + return this.http.get(`${this.baseUrl}${id}/all_hosts/`, { params }); } } diff --git a/awx/ui_next/src/api/models/Inventories.js b/awx/ui_next/src/api/models/Inventories.js index 08640173d4..858e7a390a 100644 --- a/awx/ui_next/src/api/models/Inventories.js +++ b/awx/ui_next/src/api/models/Inventories.js @@ -35,6 +35,10 @@ class Inventories extends InstanceGroupsMixin(Base) { return this.http.options(`${this.baseUrl}${id}/groups/`); } + readHostsOptions(id) { + return this.http.options(`${this.baseUrl}${id}/hosts/`); + } + promoteGroup(inventoryId, groupId) { return this.http.post(`${this.baseUrl}${inventoryId}/groups/`, { id: groupId, diff --git a/awx/ui_next/src/screens/Inventory/Inventories.jsx b/awx/ui_next/src/screens/Inventory/Inventories.jsx index d7361690f6..d6bfa12f74 100644 --- a/awx/ui_next/src/screens/Inventory/Inventories.jsx +++ b/awx/ui_next/src/screens/Inventory/Inventories.jsx @@ -32,8 +32,10 @@ class Inventories extends Component { if (!inventory) { return; } + const inventoryKind = inventory.kind === 'smart' ? 'smart_inventory' : 'inventory'; + const breadcrumbConfig = { '/inventories': i18n._(t`Inventories`), '/inventories/inventory/add': i18n._(t`Create New Inventory`), @@ -65,9 +67,7 @@ class Inventories extends Component { t`Create New Host` ), [`/inventories/${inventoryKind}/${inventory.id}/hosts/${nestedResource && - nestedResource.id}`]: i18n._( - t`${nestedResource && nestedResource.name}` - ), + nestedResource.id}`]: `${nestedResource && nestedResource.name}`, [`/inventories/${inventoryKind}/${inventory.id}/hosts/${nestedResource && nestedResource.id}/edit`]: i18n._(t`Edit Details`), [`/inventories/${inventoryKind}/${inventory.id}/hosts/${nestedResource && @@ -83,6 +83,10 @@ class Inventories extends Component { nestedResource.id}/edit`]: i18n._(t`Edit Details`), [`/inventories/${inventoryKind}/${inventory.id}/groups/${nestedResource && nestedResource.id}/details`]: i18n._(t`Group Details`), + [`/inventories/${inventoryKind}/${inventory.id}/groups/${nestedResource && + nestedResource.id}`]: `${nestedResource && nestedResource.name}`, + [`/inventories/${inventoryKind}/${inventory.id}/groups/${nestedResource && + nestedResource.id}/nested_hosts`]: i18n._(t`Hosts`), }; this.setState({ breadcrumbConfig }); }; diff --git a/awx/ui_next/src/screens/Inventory/InventoryGroup/InventoryGroup.jsx b/awx/ui_next/src/screens/Inventory/InventoryGroup/InventoryGroup.jsx index ef5b0a7995..b1d3734a5f 100644 --- a/awx/ui_next/src/screens/Inventory/InventoryGroup/InventoryGroup.jsx +++ b/awx/ui_next/src/screens/Inventory/InventoryGroup/InventoryGroup.jsx @@ -19,6 +19,8 @@ import ContentLoading from '@components/ContentLoading'; import { TabbedCardHeader } from '@components/Card'; import InventoryGroupEdit from '../InventoryGroupEdit/InventoryGroupEdit'; import InventoryGroupDetail from '../InventoryGroupDetail/InventoryGroupDetail'; +import InventoryGroupHosts from '../InventoryGroupHosts'; + import { GroupsAPI } from '@api'; function InventoryGroup({ i18n, setBreadcrumb, inventory }) { @@ -142,6 +144,12 @@ function InventoryGroup({ i18n, setBreadcrumb, inventory }) { }} />, ]} + + + + {i18n._(t`Add New Host`)} + , + + {i18n._(t`Add Existing Host`)} + , + ]; + + return ( + setIsOpen(prevState => !prevState)} + > + {i18n._(t`Add`)} + + } + dropdownItems={dropdownItems} + /> + ); +} + +AddHostDropdown.propTypes = { + onAddNew: func.isRequired, + onAddExisting: func.isRequired, +}; + +export default withI18n()(AddHostDropdown); diff --git a/awx/ui_next/src/screens/Inventory/InventoryGroupHosts/AddHostDropdown.test.jsx b/awx/ui_next/src/screens/Inventory/InventoryGroupHosts/AddHostDropdown.test.jsx new file mode 100644 index 0000000000..c72e505ea2 --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/InventoryGroupHosts/AddHostDropdown.test.jsx @@ -0,0 +1,33 @@ +import React from 'react'; +import { mountWithContexts } from '@testUtils/enzymeHelpers'; +import AddHostDropdown from './AddHostDropdown'; + +describe('', () => { + let wrapper; + let dropdownToggle; + const onAddNew = jest.fn(); + const onAddExisting = jest.fn(); + + beforeEach(() => { + wrapper = mountWithContexts( + + ); + dropdownToggle = wrapper.find('DropdownToggle button'); + }); + + test('should initially render a closed dropdown', () => { + expect(wrapper.find('DropdownItem').length).toBe(0); + }); + + test('should render two dropdown items', () => { + dropdownToggle.simulate('click'); + expect(wrapper.find('DropdownItem').length).toBe(2); + }); + + test('should close when button re-clicked', () => { + dropdownToggle.simulate('click'); + expect(wrapper.find('DropdownItem').length).toBe(2); + dropdownToggle.simulate('click'); + expect(wrapper.find('DropdownItem').length).toBe(0); + }); +}); diff --git a/awx/ui_next/src/screens/Inventory/InventoryGroupHosts/InventoryGroupHostList.jsx b/awx/ui_next/src/screens/Inventory/InventoryGroupHosts/InventoryGroupHostList.jsx new file mode 100644 index 0000000000..237c9e5c93 --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/InventoryGroupHosts/InventoryGroupHostList.jsx @@ -0,0 +1,162 @@ +import React, { useEffect, useCallback, useState } from 'react'; +import { useHistory, useLocation, useParams } from 'react-router-dom'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import { getQSConfig, parseQueryString } from '@util/qs'; +import { GroupsAPI, InventoriesAPI } from '@api'; + +import AlertModal from '@components/AlertModal'; +import DataListToolbar from '@components/DataListToolbar'; +import PaginatedDataList from '@components/PaginatedDataList'; +import useRequest from '@util/useRequest'; +import InventoryGroupHostListItem from './InventoryGroupHostListItem'; +import AddHostDropdown from './AddHostDropdown'; + +const QS_CONFIG = getQSConfig('host', { + page: 1, + page_size: 20, + order_by: 'name', +}); + +function InventoryGroupHostList({ i18n }) { + const [selected, setSelected] = useState([]); + const [isModalOpen, setIsModalOpen] = useState(false); + const { id: inventoryId, groupId } = useParams(); + const location = useLocation(); + const history = useHistory(); + + const { + result: { hosts, hostCount, actions }, + error: contentError, + isLoading, + request: fetchHosts, + } = useRequest( + useCallback(async () => { + const params = parseQueryString(QS_CONFIG, location.search); + const [response, actionsResponse] = await Promise.all([ + GroupsAPI.readAllHosts(groupId, params), + InventoriesAPI.readHostsOptions(inventoryId), + ]); + + return { + hosts: response.data.results, + hostCount: response.data.count, + actions: actionsResponse.data.actions, + }; + }, [groupId, inventoryId, location.search]), + { + hosts: [], + hostCount: 0, + } + ); + + useEffect(() => { + fetchHosts(); + }, [fetchHosts]); + + const handleSelectAll = isSelected => { + setSelected(isSelected ? [...hosts] : []); + }; + + const handleSelect = row => { + if (selected.some(s => s.id === row.id)) { + setSelected(selected.filter(s => s.id !== row.id)); + } else { + setSelected(selected.concat(row)); + } + }; + + const isAllSelected = selected.length > 0 && selected.length === hosts.length; + const canAdd = + actions && Object.prototype.hasOwnProperty.call(actions, 'POST'); + const addFormUrl = `/inventories/inventory/${inventoryId}/groups/${groupId}/nested_hosts/add`; + + return ( + <> + ( + setIsModalOpen(true)} + onAddNew={() => history.push(addFormUrl)} + />, + ] + : []), + // TODO HOST DISASSOCIATE BUTTON + ]} + /> + )} + renderItem={o => ( + row.id === o.id)} + onSelect={() => handleSelect(o)} + /> + )} + emptyStateControls={ + canAdd && ( + setIsModalOpen(true)} + onAddNew={() => history.push(addFormUrl)} + /> + ) + } + /> + + {/* DISASSOCIATE HOST MODAL PLACEHOLDER */} + + {isModalOpen && ( + setIsModalOpen(false)} + > + {/* ADD/ASSOCIATE HOST MODAL PLACEHOLDER */} + {i18n._(t`Host Select Modal`)} + + )} + + ); +} + +export default withI18n()(InventoryGroupHostList); diff --git a/awx/ui_next/src/screens/Inventory/InventoryGroupHosts/InventoryGroupHostList.test.jsx b/awx/ui_next/src/screens/Inventory/InventoryGroupHosts/InventoryGroupHostList.test.jsx new file mode 100644 index 0000000000..8345964e40 --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/InventoryGroupHosts/InventoryGroupHostList.test.jsx @@ -0,0 +1,164 @@ +import React from 'react'; +import { act } from 'react-dom/test-utils'; +import { createMemoryHistory } from 'history'; +import { GroupsAPI, InventoriesAPI } from '@api'; +import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers'; +import InventoryGroupHostList from './InventoryGroupHostList'; +import mockHosts from '../shared/data.hosts.json'; + +jest.mock('@api/models/Groups'); +jest.mock('@api/models/Inventories'); +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useParams: () => ({ + id: 1, + groupId: 2, + }), +})); + +describe('', () => { + let wrapper; + + beforeEach(async () => { + GroupsAPI.readAllHosts.mockResolvedValue({ + data: { ...mockHosts }, + }); + InventoriesAPI.readHostsOptions.mockResolvedValue({ + data: { + actions: { + GET: {}, + POST: {}, + }, + }, + }); + await act(async () => { + wrapper = mountWithContexts(); + }); + await waitForElement(wrapper, 'ContentLoading', el => el.length === 0); + }); + + afterEach(() => { + jest.clearAllMocks(); + wrapper.unmount(); + }); + + test('initially renders successfully ', () => { + expect(wrapper.find('InventoryGroupHostList').length).toBe(1); + }); + + test('should fetch inventory group hosts from api and render them in the list', () => { + expect(GroupsAPI.readAllHosts).toHaveBeenCalled(); + expect(InventoriesAPI.readHostsOptions).toHaveBeenCalled(); + expect(wrapper.find('InventoryGroupHostListItem').length).toBe(3); + }); + + test('should check and uncheck the row item', async () => { + expect( + wrapper.find('DataListCheck[id="select-host-2"]').props().checked + ).toBe(false); + await act(async () => { + wrapper.find('DataListCheck[id="select-host-2"]').invoke('onChange')(); + }); + wrapper.update(); + expect( + wrapper.find('DataListCheck[id="select-host-2"]').props().checked + ).toBe(true); + await act(async () => { + wrapper.find('DataListCheck[id="select-host-2"]').invoke('onChange')(); + }); + wrapper.update(); + expect( + wrapper.find('DataListCheck[id="select-host-2"]').props().checked + ).toBe(false); + }); + + test('should check all row items when select all is checked', async () => { + wrapper.find('DataListCheck').forEach(el => { + expect(el.props().checked).toBe(false); + }); + await act(async () => { + wrapper.find('Checkbox#select-all').invoke('onChange')(true); + }); + wrapper.update(); + wrapper.find('DataListCheck').forEach(el => { + expect(el.props().checked).toBe(true); + }); + await act(async () => { + wrapper.find('Checkbox#select-all').invoke('onChange')(false); + }); + wrapper.update(); + wrapper.find('DataListCheck').forEach(el => { + expect(el.props().checked).toBe(false); + }); + }); + + test('should show add dropdown button according to permissions', async () => { + expect(wrapper.find('AddHostDropdown').length).toBe(1); + InventoriesAPI.readHostsOptions.mockResolvedValueOnce({ + data: { + actions: { + GET: {}, + }, + }, + }); + await act(async () => { + wrapper = mountWithContexts(); + }); + await waitForElement(wrapper, 'ContentLoading', el => el.length === 0); + expect(wrapper.find('AddHostDropdown').length).toBe(0); + }); + + test('should show associate host modal when adding an existing host', () => { + const dropdownToggle = wrapper.find( + 'DropdownToggle button[aria-label="add host"]' + ); + dropdownToggle.simulate('click'); + wrapper + .find('DropdownItem[aria-label="add existing host"]') + .simulate('click'); + expect(wrapper.find('AlertModal').length).toBe(1); + wrapper.find('ModalBoxCloseButton').simulate('click'); + expect(wrapper.find('AlertModal').length).toBe(0); + }); + + test('should navigate to host add form when adding a new host', async () => { + GroupsAPI.readAllHosts.mockResolvedValue({ + data: { ...mockHosts }, + }); + InventoriesAPI.readHostsOptions.mockResolvedValue({ + data: { + actions: { + GET: {}, + POST: {}, + }, + }, + }); + const history = createMemoryHistory(); + await act(async () => { + wrapper = mountWithContexts(, { + context: { + router: { history }, + }, + }); + }); + await waitForElement(wrapper, 'ContentLoading', el => el.length === 0); + const dropdownToggle = wrapper.find( + 'DropdownToggle button[aria-label="add host"]' + ); + dropdownToggle.simulate('click'); + wrapper.find('DropdownItem[aria-label="add new host"]').simulate('click'); + expect(history.location.pathname).toEqual( + '/inventories/inventory/1/groups/2/nested_hosts/add' + ); + }); + + test('should show content error when api throws error on initial render', async () => { + InventoriesAPI.readHostsOptions.mockImplementationOnce(() => + Promise.reject(new Error()) + ); + await act(async () => { + wrapper = mountWithContexts(); + }); + await waitForElement(wrapper, 'ContentError', el => el.length === 1); + }); +}); diff --git a/awx/ui_next/src/screens/Inventory/InventoryGroupHosts/InventoryGroupHostListItem.jsx b/awx/ui_next/src/screens/Inventory/InventoryGroupHosts/InventoryGroupHostListItem.jsx new file mode 100644 index 0000000000..a234b9597f --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/InventoryGroupHosts/InventoryGroupHostListItem.jsx @@ -0,0 +1,98 @@ +import React from 'react'; +import { Link } from 'react-router-dom'; +import { string, bool, func } from 'prop-types'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; + +import { + Button, + DataListAction as _DataListAction, + DataListCell, + DataListCheck, + DataListItem, + DataListItemCells, + DataListItemRow, + Tooltip, +} from '@patternfly/react-core'; +import { PencilAltIcon } from '@patternfly/react-icons'; +import HostToggle from '@components/HostToggle'; +import Sparkline from '@components/Sparkline'; +import { Host } from '@types'; +import styled from 'styled-components'; + +const DataListAction = styled(_DataListAction)` + align-items: center; + display: grid; + grid-gap: 24px; + grid-template-columns: min-content 40px; +`; + +function InventoryGroupHostListItem({ + i18n, + detailUrl, + editUrl, + host, + isSelected, + onSelect, +}) { + const recentPlaybookJobs = host.summary_fields.recent_jobs.map(job => ({ + ...job, + type: 'job', + })); + + const labelId = `check-action-${host.id}`; + + return ( + + + + + + {host.name} + + , + + + , + ]} + /> + + + {host.summary_fields.user_capabilities?.edit && ( + + + + )} + + + + ); +} + +InventoryGroupHostListItem.propTypes = { + detailUrl: string.isRequired, + editUrl: string.isRequired, + host: Host.isRequired, + isSelected: bool.isRequired, + onSelect: func.isRequired, +}; + +export default withI18n()(InventoryGroupHostListItem); diff --git a/awx/ui_next/src/screens/Inventory/InventoryGroupHosts/InventoryGroupHostListItem.test.jsx b/awx/ui_next/src/screens/Inventory/InventoryGroupHosts/InventoryGroupHostListItem.test.jsx new file mode 100644 index 0000000000..830bd5540e --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/InventoryGroupHosts/InventoryGroupHostListItem.test.jsx @@ -0,0 +1,57 @@ +import React from 'react'; +import { mountWithContexts } from '@testUtils/enzymeHelpers'; +import InventoryGroupHostListItem from './InventoryGroupHostListItem'; +import mockHosts from '../shared/data.hosts.json'; + +jest.mock('@api'); + +describe('', () => { + let wrapper; + const mockHost = mockHosts.results[0]; + + beforeEach(() => { + wrapper = mountWithContexts( + {}} + /> + ); + }); + + afterEach(() => { + wrapper.unmount(); + }); + + test('should display expected row item content', () => { + expect( + wrapper + .find('DataListCell') + .first() + .text() + ).toBe('.host-000001.group-00000.dummy'); + expect(wrapper.find('Sparkline').length).toBe(1); + expect(wrapper.find('HostToggle').length).toBe(1); + }); + + test('edit button shown to users with edit capabilities', () => { + expect(wrapper.find('PencilAltIcon').exists()).toBeTruthy(); + }); + + test('edit button hidden from users without edit capabilities', () => { + const copyMockHost = Object.assign({}, mockHost); + copyMockHost.summary_fields.user_capabilities.edit = false; + wrapper = mountWithContexts( + {}} + /> + ); + expect(wrapper.find('PencilAltIcon').exists()).toBeFalsy(); + }); +}); diff --git a/awx/ui_next/src/screens/Inventory/InventoryGroupHosts/InventoryGroupHosts.jsx b/awx/ui_next/src/screens/Inventory/InventoryGroupHosts/InventoryGroupHosts.jsx new file mode 100644 index 0000000000..dc3da57781 --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/InventoryGroupHosts/InventoryGroupHosts.jsx @@ -0,0 +1,16 @@ +import React from 'react'; +import { Switch, Route } from 'react-router-dom'; +import InventoryGroupHostList from './InventoryGroupHostList'; + +function InventoryGroupHosts() { + return ( + + {/* Route to InventoryGroupHostAddForm */} + + + + + ); +} + +export default InventoryGroupHosts; diff --git a/awx/ui_next/src/screens/Inventory/InventoryGroupHosts/InventoryGroupHosts.test.jsx b/awx/ui_next/src/screens/Inventory/InventoryGroupHosts/InventoryGroupHosts.test.jsx new file mode 100644 index 0000000000..0a4cecc19d --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/InventoryGroupHosts/InventoryGroupHosts.test.jsx @@ -0,0 +1,27 @@ +import React from 'react'; +import { mountWithContexts } from '@testUtils/enzymeHelpers'; +import { act } from 'react-dom/test-utils'; +import { createMemoryHistory } from 'history'; +import InventoryGroupHosts from './InventoryGroupHosts'; + +jest.mock('@api'); + +describe('', () => { + let wrapper; + + test('initially renders successfully', async () => { + const history = createMemoryHistory({ + initialEntries: ['/inventories/inventory/1/groups/1/nested_hosts'], + }); + + await act(async () => { + wrapper = mountWithContexts(, { + context: { + router: { history, route: { location: history.location } }, + }, + }); + }); + expect(wrapper.length).toBe(1); + expect(wrapper.find('InventoryGroupHostList').length).toBe(1); + }); +}); diff --git a/awx/ui_next/src/screens/Inventory/InventoryGroupHosts/index.js b/awx/ui_next/src/screens/Inventory/InventoryGroupHosts/index.js new file mode 100644 index 0000000000..58e24ac90e --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/InventoryGroupHosts/index.js @@ -0,0 +1 @@ +export { default } from './InventoryGroupHosts'; diff --git a/awx/ui_next/src/screens/Inventory/shared/data.hosts.json b/awx/ui_next/src/screens/Inventory/shared/data.hosts.json new file mode 100644 index 0000000000..07c6ef7d9f --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/shared/data.hosts.json @@ -0,0 +1,393 @@ + +{ + "count": 3, + "results": [ + { + "id": 2, + "type": "host", + "url": "/api/v2/hosts/2/", + "related": { + "created_by": "/api/v2/users/10/", + "modified_by": "/api/v2/users/19/", + "variable_data": "/api/v2/hosts/2/variable_data/", + "groups": "/api/v2/hosts/2/groups/", + "all_groups": "/api/v2/hosts/2/all_groups/", + "job_events": "/api/v2/hosts/2/job_events/", + "job_host_summaries": "/api/v2/hosts/2/job_host_summaries/", + "activity_stream": "/api/v2/hosts/2/activity_stream/", + "inventory_sources": "/api/v2/hosts/2/inventory_sources/", + "smart_inventories": "/api/v2/hosts/2/smart_inventories/", + "ad_hoc_commands": "/api/v2/hosts/2/ad_hoc_commands/", + "ad_hoc_command_events": "/api/v2/hosts/2/ad_hoc_command_events/", + "insights": "/api/v2/hosts/2/insights/", + "ansible_facts": "/api/v2/hosts/2/ansible_facts/", + "inventory": "/api/v2/inventories/2/", + "last_job": "/api/v2/jobs/236/", + "last_job_host_summary": "/api/v2/job_host_summaries/2202/" + }, + "summary_fields": { + "inventory": { + "id": 2, + "name": " Inventory 1 Org 0", + "description": "", + "has_active_failures": false, + "total_hosts": 33, + "hosts_with_active_failures": 0, + "total_groups": 4, + "has_inventory_sources": false, + "total_inventory_sources": 0, + "inventory_sources_with_failures": 0, + "organization_id": 2, + "kind": "" + }, + "last_job": { + "id": 236, + "name": " Job Template 1 Project 0", + "description": "", + "finished": "2020-02-26T03:15:21.471439Z", + "status": "successful", + "failed": false, + "job_template_id": 18, + "job_template_name": " Job Template 1 Project 0" + }, + "last_job_host_summary": { + "id": 2202, + "failed": false + }, + "created_by": { + "id": 10, + "username": "user-3", + "first_name": "", + "last_name": "" + }, + "modified_by": { + "id": 19, + "username": "all", + "first_name": "", + "last_name": "" + }, + "user_capabilities": { + "edit": true, + "delete": true + }, + "groups": { + "count": 2, + "results": [ + { + "id": 1, + "name": " Group 1 Inventory 0" + }, + { + "id": 2, + "name": " Group 2 Inventory 0" + } + ] + }, + "recent_jobs": [ + { + "id": 236, + "name": " Job Template 1 Project 0", + "status": "successful", + "finished": "2020-02-26T03:15:21.471439Z" + }, + { + "id": 232, + "name": " Job Template 1 Project 0", + "status": "successful", + "finished": "2020-02-25T21:20:33.593789Z" + }, + { + "id": 229, + "name": " Job Template 1 Project 0", + "status": "successful", + "finished": "2020-02-25T16:19:46.364134Z" + }, + { + "id": 228, + "name": " Job Template 1 Project 0", + "status": "successful", + "finished": "2020-02-25T16:18:54.138363Z" + }, + { + "id": 225, + "name": " Job Template 1 Project 0", + "status": "successful", + "finished": "2020-02-25T15:55:32.247652Z" + } + ] + }, + "created": "2020-02-24T15:10:58.922179Z", + "modified": "2020-02-26T21:52:43.428530Z", + "name": ".host-000001.group-00000.dummy", + "description": "", + "inventory": 2, + "enabled": false, + "instance_id": "", + "variables": "", + "has_active_failures": false, + "has_inventory_sources": false, + "last_job": 236, + "last_job_host_summary": 2202, + "insights_system_id": null, + "ansible_facts_modified": null + }, + { + "id": 3, + "type": "host", + "url": "/api/v2/hosts/3/", + "related": { + "created_by": "/api/v2/users/11/", + "modified_by": "/api/v2/users/1/", + "variable_data": "/api/v2/hosts/3/variable_data/", + "groups": "/api/v2/hosts/3/groups/", + "all_groups": "/api/v2/hosts/3/all_groups/", + "job_events": "/api/v2/hosts/3/job_events/", + "job_host_summaries": "/api/v2/hosts/3/job_host_summaries/", + "activity_stream": "/api/v2/hosts/3/activity_stream/", + "inventory_sources": "/api/v2/hosts/3/inventory_sources/", + "smart_inventories": "/api/v2/hosts/3/smart_inventories/", + "ad_hoc_commands": "/api/v2/hosts/3/ad_hoc_commands/", + "ad_hoc_command_events": "/api/v2/hosts/3/ad_hoc_command_events/", + "insights": "/api/v2/hosts/3/insights/", + "ansible_facts": "/api/v2/hosts/3/ansible_facts/", + "inventory": "/api/v2/inventories/2/", + "last_job": "/api/v2/jobs/236/", + "last_job_host_summary": "/api/v2/job_host_summaries/2195/" + }, + "summary_fields": { + "inventory": { + "id": 2, + "name": " Inventory 1 Org 0", + "description": "", + "has_active_failures": false, + "total_hosts": 33, + "hosts_with_active_failures": 0, + "total_groups": 4, + "has_inventory_sources": false, + "total_inventory_sources": 0, + "inventory_sources_with_failures": 0, + "organization_id": 2, + "kind": "" + }, + "last_job": { + "id": 236, + "name": " Job Template 1 Project 0", + "description": "", + "finished": "2020-02-26T03:15:21.471439Z", + "status": "successful", + "failed": false, + "job_template_id": 18, + "job_template_name": " Job Template 1 Project 0" + }, + "last_job_host_summary": { + "id": 2195, + "failed": false + }, + "created_by": { + "id": 11, + "username": "user-4", + "first_name": "", + "last_name": "" + }, + "modified_by": { + "id": 1, + "username": "admin", + "first_name": "", + "last_name": "" + }, + "user_capabilities": { + "edit": true, + "delete": true + }, + "groups": { + "count": 2, + "results": [ + { + "id": 1, + "name": " Group 1 Inventory 0" + }, + { + "id": 2, + "name": " Group 2 Inventory 0" + } + ] + }, + "recent_jobs": [ + { + "id": 236, + "name": " Job Template 1 Project 0", + "status": "successful", + "finished": "2020-02-26T03:15:21.471439Z" + }, + { + "id": 232, + "name": " Job Template 1 Project 0", + "status": "successful", + "finished": "2020-02-25T21:20:33.593789Z" + }, + { + "id": 229, + "name": " Job Template 1 Project 0", + "status": "successful", + "finished": "2020-02-25T16:19:46.364134Z" + }, + { + "id": 228, + "name": " Job Template 1 Project 0", + "status": "successful", + "finished": "2020-02-25T16:18:54.138363Z" + }, + { + "id": 225, + "name": " Job Template 1 Project 0", + "status": "successful", + "finished": "2020-02-25T15:55:32.247652Z" + } + ] + }, + "created": "2020-02-24T15:10:58.945113Z", + "modified": "2020-02-27T03:43:43.635871Z", + "name": ".host-000002.group-00000.dummy", + "description": "", + "inventory": 2, + "enabled": false, + "instance_id": "", + "variables": "", + "has_active_failures": false, + "has_inventory_sources": false, + "last_job": 236, + "last_job_host_summary": 2195, + "insights_system_id": null, + "ansible_facts_modified": null + }, + { + "id": 4, + "type": "host", + "url": "/api/v2/hosts/4/", + "related": { + "created_by": "/api/v2/users/12/", + "modified_by": "/api/v2/users/1/", + "variable_data": "/api/v2/hosts/4/variable_data/", + "groups": "/api/v2/hosts/4/groups/", + "all_groups": "/api/v2/hosts/4/all_groups/", + "job_events": "/api/v2/hosts/4/job_events/", + "job_host_summaries": "/api/v2/hosts/4/job_host_summaries/", + "activity_stream": "/api/v2/hosts/4/activity_stream/", + "inventory_sources": "/api/v2/hosts/4/inventory_sources/", + "smart_inventories": "/api/v2/hosts/4/smart_inventories/", + "ad_hoc_commands": "/api/v2/hosts/4/ad_hoc_commands/", + "ad_hoc_command_events": "/api/v2/hosts/4/ad_hoc_command_events/", + "insights": "/api/v2/hosts/4/insights/", + "ansible_facts": "/api/v2/hosts/4/ansible_facts/", + "inventory": "/api/v2/inventories/2/", + "last_job": "/api/v2/jobs/236/", + "last_job_host_summary": "/api/v2/job_host_summaries/2192/" + }, + "summary_fields": { + "inventory": { + "id": 2, + "name": " Inventory 1 Org 0", + "description": "", + "has_active_failures": false, + "total_hosts": 33, + "hosts_with_active_failures": 0, + "total_groups": 4, + "has_inventory_sources": false, + "total_inventory_sources": 0, + "inventory_sources_with_failures": 0, + "organization_id": 2, + "kind": "" + }, + "last_job": { + "id": 236, + "name": " Job Template 1 Project 0", + "description": "", + "finished": "2020-02-26T03:15:21.471439Z", + "status": "successful", + "failed": false, + "job_template_id": 18, + "job_template_name": " Job Template 1 Project 0" + }, + "last_job_host_summary": { + "id": 2192, + "failed": false + }, + "created_by": { + "id": 12, + "username": "user-5", + "first_name": "", + "last_name": "" + }, + "modified_by": { + "id": 1, + "username": "admin", + "first_name": "", + "last_name": "" + }, + "user_capabilities": { + "edit": true, + "delete": true + }, + "groups": { + "count": 2, + "results": [ + { + "id": 1, + "name": " Group 1 Inventory 0" + }, + { + "id": 2, + "name": " Group 2 Inventory 0" + } + ] + }, + "recent_jobs": [ + { + "id": 236, + "name": " Job Template 1 Project 0", + "status": "successful", + "finished": "2020-02-26T03:15:21.471439Z" + }, + { + "id": 232, + "name": " Job Template 1 Project 0", + "status": "successful", + "finished": "2020-02-25T21:20:33.593789Z" + }, + { + "id": 229, + "name": " Job Template 1 Project 0", + "status": "successful", + "finished": "2020-02-25T16:19:46.364134Z" + }, + { + "id": 228, + "name": " Job Template 1 Project 0", + "status": "successful", + "finished": "2020-02-25T16:18:54.138363Z" + }, + { + "id": 225, + "name": " Job Template 1 Project 0", + "status": "successful", + "finished": "2020-02-25T15:55:32.247652Z" + } + ] + }, + "created": "2020-02-24T15:10:58.962312Z", + "modified": "2020-02-27T03:43:45.528882Z", + "name": ".host-000003.group-00000.dummy", + "description": "", + "inventory": 2, + "enabled": false, + "instance_id": "", + "variables": "", + "has_active_failures": false, + "has_inventory_sources": false, + "last_job": 236, + "last_job_host_summary": 2192, + "insights_system_id": null, + "ansible_facts_modified": null + } + ] +}