mirror of
https://github.com/ansible/awx.git
synced 2026-03-01 00:38:45 -03:30
Add smart inventory host detail view
* Remove host toggle from smart inv host list
This commit is contained in:
@@ -111,7 +111,7 @@ function SmartInventory({ i18n, setBreadcrumb }) {
|
|||||||
|
|
||||||
let showCardHeader = true;
|
let showCardHeader = true;
|
||||||
|
|
||||||
if (location.pathname.endsWith('edit')) {
|
if (['edit', 'hosts/'].some(name => location.pathname.includes(name))) {
|
||||||
showCardHeader = false;
|
showCardHeader = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -145,7 +145,10 @@ function SmartInventory({ i18n, setBreadcrumb }) {
|
|||||||
/>
|
/>
|
||||||
</Route>,
|
</Route>,
|
||||||
<Route key="hosts" path="/inventories/smart_inventory/:id/hosts">
|
<Route key="hosts" path="/inventories/smart_inventory/:id/hosts">
|
||||||
<SmartInventoryHosts inventory={inventory} />
|
<SmartInventoryHosts
|
||||||
|
inventory={inventory}
|
||||||
|
setBreadcrumb={setBreadcrumb}
|
||||||
|
/>
|
||||||
</Route>,
|
</Route>,
|
||||||
<Route
|
<Route
|
||||||
key="completed_jobs"
|
key="completed_jobs"
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import React, { useEffect, useCallback } from 'react';
|
||||||
|
import { withI18n } from '@lingui/react';
|
||||||
|
import { t } from '@lingui/macro';
|
||||||
|
import { Link, Redirect, Route, Switch, useRouteMatch } from 'react-router-dom';
|
||||||
|
import { CaretLeftIcon } from '@patternfly/react-icons';
|
||||||
|
import ContentError from '../../../components/ContentError';
|
||||||
|
import ContentLoading from '../../../components/ContentLoading';
|
||||||
|
import RoutedTabs from '../../../components/RoutedTabs';
|
||||||
|
import SmartInventoryHostDetail from '../SmartInventoryHostDetail';
|
||||||
|
import useRequest from '../../../util/useRequest';
|
||||||
|
import { InventoriesAPI } from '../../../api';
|
||||||
|
|
||||||
|
function SmartInventoryHost({ i18n, inventory, setBreadcrumb }) {
|
||||||
|
const { params, path, url } = useRouteMatch(
|
||||||
|
'/inventories/smart_inventory/:id/hosts/:hostId'
|
||||||
|
);
|
||||||
|
|
||||||
|
const { result: host, error, isLoading, request: fetchHost } = useRequest(
|
||||||
|
useCallback(async () => {
|
||||||
|
const response = await InventoriesAPI.readHostDetail(
|
||||||
|
inventory.id,
|
||||||
|
params.hostId
|
||||||
|
);
|
||||||
|
return response;
|
||||||
|
}, [inventory.id, params.hostId]),
|
||||||
|
null
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchHost();
|
||||||
|
}, [fetchHost]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (inventory && host) {
|
||||||
|
setBreadcrumb(inventory, host);
|
||||||
|
}
|
||||||
|
}, [inventory, host, setBreadcrumb]);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return <ContentError error={error} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tabsArray = [
|
||||||
|
{
|
||||||
|
name: (
|
||||||
|
<>
|
||||||
|
<CaretLeftIcon />
|
||||||
|
{i18n._(t`Back to Hosts`)}
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
link: `/inventories/smart_inventory/${inventory.id}/hosts`,
|
||||||
|
id: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: i18n._(t`Details`),
|
||||||
|
link: `${url}/details`,
|
||||||
|
id: 1,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<RoutedTabs tabsArray={tabsArray} />
|
||||||
|
|
||||||
|
{isLoading && <ContentLoading />}
|
||||||
|
|
||||||
|
{!isLoading && host && (
|
||||||
|
<Switch>
|
||||||
|
<Redirect
|
||||||
|
from="/inventories/smart_inventory/:id/hosts/:hostId"
|
||||||
|
to={`${path}/details`}
|
||||||
|
exact
|
||||||
|
/>
|
||||||
|
<Route key="details" path={`${path}/details`}>
|
||||||
|
<SmartInventoryHostDetail host={host} />
|
||||||
|
</Route>
|
||||||
|
<Route key="not-found" path="*">
|
||||||
|
<ContentError isNotFound>
|
||||||
|
<Link to={`${url}/details`}>
|
||||||
|
{i18n._(t`View smart inventory host details`)}
|
||||||
|
</Link>
|
||||||
|
</ContentError>
|
||||||
|
</Route>
|
||||||
|
</Switch>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default withI18n()(SmartInventoryHost);
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { act } from 'react-dom/test-utils';
|
||||||
|
import { createMemoryHistory } from 'history';
|
||||||
|
import { InventoriesAPI } from '../../../api';
|
||||||
|
import {
|
||||||
|
mountWithContexts,
|
||||||
|
waitForElement,
|
||||||
|
} from '../../../../testUtils/enzymeHelpers';
|
||||||
|
import mockHost from '../shared/data.host.json';
|
||||||
|
import SmartInventoryHost from './SmartInventoryHost';
|
||||||
|
|
||||||
|
jest.mock('../../../api');
|
||||||
|
jest.mock('react-router-dom', () => ({
|
||||||
|
...jest.requireActual('react-router-dom'),
|
||||||
|
useRouteMatch: () => ({
|
||||||
|
params: { id: 1234, hostId: 2 },
|
||||||
|
path: '/inventories/smart_inventory/:id/hosts/:hostId',
|
||||||
|
url: '/inventories/smart_inventory/1234/hosts/2',
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockSmartInventory = {
|
||||||
|
id: 1234,
|
||||||
|
name: 'Mock Smart Inventory',
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('<SmartInventoryHost />', () => {
|
||||||
|
let wrapper;
|
||||||
|
let history;
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
wrapper.unmount();
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should render expected tabs', async () => {
|
||||||
|
InventoriesAPI.readHostDetail.mockResolvedValue({
|
||||||
|
data: { ...mockHost },
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
wrapper = mountWithContexts(
|
||||||
|
<SmartInventoryHost
|
||||||
|
inventory={mockSmartInventory}
|
||||||
|
setBreadcrumb={() => {}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const expectedTabs = ['Back to Hosts', 'Details'];
|
||||||
|
|
||||||
|
expect(wrapper.find('RoutedTabs li').length).toBe(2);
|
||||||
|
wrapper.find('RoutedTabs li').forEach((tab, index) => {
|
||||||
|
expect(tab.text()).toEqual(expectedTabs[index]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should show content error when api throws error on initial render', async () => {
|
||||||
|
InventoriesAPI.readHostDetail.mockRejectedValueOnce(new Error());
|
||||||
|
await act(async () => {
|
||||||
|
wrapper = mountWithContexts(
|
||||||
|
<SmartInventoryHost
|
||||||
|
inventory={mockSmartInventory}
|
||||||
|
setBreadcrumb={() => {}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
|
||||||
|
await waitForElement(wrapper, 'ContentError', el => el.length === 1);
|
||||||
|
expect(wrapper.find('ContentError Title').text()).toEqual(
|
||||||
|
'Something went wrong...'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should show content error when user attempts to navigate to erroneous route', async () => {
|
||||||
|
history = createMemoryHistory({
|
||||||
|
initialEntries: ['/inventories/smart_inventory/1/hosts/1/foobar'],
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
wrapper = mountWithContexts(
|
||||||
|
<SmartInventoryHost
|
||||||
|
inventory={mockSmartInventory}
|
||||||
|
setBreadcrumb={() => {}}
|
||||||
|
/>,
|
||||||
|
{ context: { router: { history } } }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await waitForElement(wrapper, 'ContentError', el => el.length === 1);
|
||||||
|
expect(wrapper.find('ContentError Title').text()).toEqual('Not Found');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { default } from './SmartInventoryHost';
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { withI18n } from '@lingui/react';
|
||||||
|
import { t } from '@lingui/macro';
|
||||||
|
import { Host } from '../../../types';
|
||||||
|
import { CardBody } from '../../../components/Card';
|
||||||
|
import {
|
||||||
|
Detail,
|
||||||
|
DetailList,
|
||||||
|
UserDateDetail,
|
||||||
|
} from '../../../components/DetailList';
|
||||||
|
import Sparkline from '../../../components/Sparkline';
|
||||||
|
import { VariablesDetail } from '../../../components/CodeMirrorInput';
|
||||||
|
|
||||||
|
function SmartInventoryHostDetail({ host, i18n }) {
|
||||||
|
const {
|
||||||
|
created,
|
||||||
|
description,
|
||||||
|
enabled,
|
||||||
|
modified,
|
||||||
|
name,
|
||||||
|
variables,
|
||||||
|
summary_fields: { inventory, recent_jobs, created_by, modified_by },
|
||||||
|
} = host;
|
||||||
|
|
||||||
|
const recentPlaybookJobs = recent_jobs?.map(job => ({ ...job, type: 'job' }));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CardBody>
|
||||||
|
<DetailList gutter="sm">
|
||||||
|
<Detail label={i18n._(t`Name`)} value={name} />
|
||||||
|
{recentPlaybookJobs?.length > 0 && (
|
||||||
|
<Detail
|
||||||
|
label={i18n._(t`Activity`)}
|
||||||
|
value={<Sparkline jobs={recentPlaybookJobs} />}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Detail label={i18n._(t`Description`)} value={description} />
|
||||||
|
<Detail
|
||||||
|
label={i18n._(t`Inventory`)}
|
||||||
|
value={
|
||||||
|
<Link to={`/inventories/inventory/${inventory?.id}/details`}>
|
||||||
|
{inventory?.name}
|
||||||
|
</Link>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Detail
|
||||||
|
label={i18n._(t`Enabled`)}
|
||||||
|
value={enabled ? i18n._(t`On`) : i18n._(t`Off`)}
|
||||||
|
/>
|
||||||
|
<UserDateDetail
|
||||||
|
date={created}
|
||||||
|
label={i18n._(t`Created`)}
|
||||||
|
user={created_by}
|
||||||
|
/>
|
||||||
|
<UserDateDetail
|
||||||
|
date={modified}
|
||||||
|
label={i18n._(t`Last modified`)}
|
||||||
|
user={modified_by}
|
||||||
|
/>
|
||||||
|
<VariablesDetail
|
||||||
|
label={i18n._(t`Variables`)}
|
||||||
|
rows={4}
|
||||||
|
value={variables}
|
||||||
|
/>
|
||||||
|
</DetailList>
|
||||||
|
</CardBody>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
SmartInventoryHostDetail.propTypes = {
|
||||||
|
host: Host.isRequired,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default withI18n()(SmartInventoryHostDetail);
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { mountWithContexts } from '../../../../testUtils/enzymeHelpers';
|
||||||
|
import SmartInventoryHostDetail from './SmartInventoryHostDetail';
|
||||||
|
import mockHost from '../shared/data.host.json';
|
||||||
|
|
||||||
|
jest.mock('../../../api');
|
||||||
|
|
||||||
|
describe('<SmartInventoryHostDetail />', () => {
|
||||||
|
let wrapper;
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
wrapper = mountWithContexts(<SmartInventoryHostDetail host={mockHost} />);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
wrapper.unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should render Details', () => {
|
||||||
|
function assertDetail(label, value) {
|
||||||
|
expect(wrapper.find(`Detail[label="${label}"] dt`).text()).toBe(label);
|
||||||
|
expect(wrapper.find(`Detail[label="${label}"] dd`).text()).toBe(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
assertDetail('Name', 'localhost');
|
||||||
|
assertDetail('Description', 'localhost description');
|
||||||
|
assertDetail('Inventory', 'Mikes Inventory');
|
||||||
|
assertDetail('Enabled', 'On');
|
||||||
|
assertDetail('Created', '10/28/2019, 9:26:54 PM');
|
||||||
|
assertDetail('Last modified', '10/29/2019, 8:18:41 PM');
|
||||||
|
expect(wrapper.find('Detail[label="Activity"] Sparkline')).toHaveLength(1);
|
||||||
|
expect(wrapper.find('VariablesDetail')).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { default } from './SmartInventoryHostDetail';
|
||||||
@@ -2,18 +2,16 @@ import React from 'react';
|
|||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { string, bool, func } from 'prop-types';
|
import { string, bool, func } from 'prop-types';
|
||||||
import { withI18n } from '@lingui/react';
|
import { withI18n } from '@lingui/react';
|
||||||
import { t, Trans } from '@lingui/macro';
|
import { t } from '@lingui/macro';
|
||||||
import 'styled-components/macro';
|
import 'styled-components/macro';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
DataListAction,
|
|
||||||
DataListCheck,
|
DataListCheck,
|
||||||
DataListItem,
|
DataListItem,
|
||||||
DataListItemCells,
|
DataListItemCells,
|
||||||
DataListItemRow,
|
DataListItemRow,
|
||||||
} from '@patternfly/react-core';
|
} from '@patternfly/react-core';
|
||||||
import DataListCell from '../../../components/DataListCell';
|
import DataListCell from '../../../components/DataListCell';
|
||||||
import HostToggle from '../../../components/HostToggle';
|
|
||||||
import Sparkline from '../../../components/Sparkline';
|
import Sparkline from '../../../components/Sparkline';
|
||||||
import { Host } from '../../../types';
|
import { Host } from '../../../types';
|
||||||
|
|
||||||
@@ -62,25 +60,6 @@ function SmartInventoryHostListItem({
|
|||||||
</DataListCell>,
|
</DataListCell>,
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
<DataListAction
|
|
||||||
aria-label="actions"
|
|
||||||
aria-labelledby={labelId}
|
|
||||||
id={labelId}
|
|
||||||
>
|
|
||||||
<HostToggle
|
|
||||||
isDisabled
|
|
||||||
host={host}
|
|
||||||
tooltip={
|
|
||||||
<Trans>
|
|
||||||
<b>Smart inventory hosts are read-only.</b>
|
|
||||||
<br />
|
|
||||||
Toggle indicates if a host is available and should be included
|
|
||||||
in running jobs. For hosts that are part of an external
|
|
||||||
inventory, this may be reset by the inventory sync process.
|
|
||||||
</Trans>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</DataListAction>
|
|
||||||
</DataListItemRow>
|
</DataListItemRow>
|
||||||
</DataListItem>
|
</DataListItem>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -44,9 +44,4 @@ describe('<SmartInventoryHostListItem />', () => {
|
|||||||
expect(cells.at(1).find('Sparkline').length).toEqual(1);
|
expect(cells.at(1).find('Sparkline').length).toEqual(1);
|
||||||
expect(cells.at(2).text()).toContain('Inv 1');
|
expect(cells.at(2).text()).toContain('Inv 1');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should display disabled host toggle', () => {
|
|
||||||
expect(wrapper.find('HostToggle').length).toBe(1);
|
|
||||||
expect(wrapper.find('HostToggle Switch').prop('isDisabled')).toEqual(true);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,13 +1,22 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Route } from 'react-router-dom';
|
import { Route, Switch } from 'react-router-dom';
|
||||||
import SmartInventoryHostList from './SmartInventoryHostList';
|
import SmartInventoryHostList from './SmartInventoryHostList';
|
||||||
|
import SmartInventoryHost from '../SmartInventoryHost';
|
||||||
import { Inventory } from '../../../types';
|
import { Inventory } from '../../../types';
|
||||||
|
|
||||||
function SmartInventoryHosts({ inventory }) {
|
function SmartInventoryHosts({ inventory, setBreadcrumb }) {
|
||||||
return (
|
return (
|
||||||
<Route key="host-list" path="/inventories/smart_inventory/:id/hosts">
|
<Switch>
|
||||||
<SmartInventoryHostList inventory={inventory} />
|
<Route key="host" path="/inventories/smart_inventory/:id/hosts/:hostId">
|
||||||
</Route>
|
<SmartInventoryHost
|
||||||
|
setBreadcrumb={setBreadcrumb}
|
||||||
|
inventory={inventory}
|
||||||
|
/>
|
||||||
|
</Route>
|
||||||
|
<Route key="host-list" path="/inventories/smart_inventory/:id/hosts">
|
||||||
|
<SmartInventoryHostList inventory={inventory} />
|
||||||
|
</Route>
|
||||||
|
</Switch>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
import { act } from 'react-dom/test-utils';
|
||||||
import { createMemoryHistory } from 'history';
|
import { createMemoryHistory } from 'history';
|
||||||
import { mountWithContexts } from '../../../../testUtils/enzymeHelpers';
|
import {
|
||||||
|
mountWithContexts,
|
||||||
|
waitForElement,
|
||||||
|
} from '../../../../testUtils/enzymeHelpers';
|
||||||
import SmartInventoryHosts from './SmartInventoryHosts';
|
import SmartInventoryHosts from './SmartInventoryHosts';
|
||||||
|
|
||||||
jest.mock('../../../api');
|
jest.mock('../../../api');
|
||||||
@@ -25,4 +29,28 @@ describe('<SmartInventoryHosts />', () => {
|
|||||||
jest.clearAllMocks();
|
jest.clearAllMocks();
|
||||||
wrapper.unmount();
|
wrapper.unmount();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('should render smart inventory host details', async () => {
|
||||||
|
let wrapper;
|
||||||
|
const history = createMemoryHistory({
|
||||||
|
initialEntries: ['/inventories/smart_inventory/1/hosts/2'],
|
||||||
|
});
|
||||||
|
const match = {
|
||||||
|
path: '/inventories/smart_inventory/:id/hosts/:hostId',
|
||||||
|
url: '/inventories/smart_inventory/1/hosts/2',
|
||||||
|
isExact: true,
|
||||||
|
};
|
||||||
|
await act(async () => {
|
||||||
|
wrapper = mountWithContexts(
|
||||||
|
<SmartInventoryHosts inventory={{ id: 1 }} setBreadcrumb={() => {}} />,
|
||||||
|
{
|
||||||
|
context: { router: { history, route: { match } } },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
|
||||||
|
expect(wrapper.find('SmartInventoryHost').length).toBe(1);
|
||||||
|
jest.clearAllMocks();
|
||||||
|
wrapper.unmount();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user