Merge pull request #13598 from marshmalien/constructed-inventory-sync-button

Add constructed inventory detail sync button
This commit is contained in:
Marliana Lara 2023-02-22 10:20:45 -05:00 committed by GitHub
commit f1cab55051
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 509 additions and 102 deletions

View File

@ -13,7 +13,7 @@ class Inventories extends InstanceGroupsMixin(Base) {
this.readGroups = this.readGroups.bind(this);
this.readGroupsOptions = this.readGroupsOptions.bind(this);
this.promoteGroup = this.promoteGroup.bind(this);
this.readSourceInventories = this.readSourceInventories.bind(this);
this.readInputInventories = this.readInputInventories.bind(this);
}
readAccessList(id, params) {
@ -73,7 +73,7 @@ class Inventories extends InstanceGroupsMixin(Base) {
});
}
readSourceInventories(inventoryId, params) {
readInputInventories(inventoryId, params) {
return this.http.get(`${this.baseUrl}${inventoryId}/input_inventories/`, {
params,
});

View File

@ -5,54 +5,103 @@ import { t } from '@lingui/macro';
import {
Button,
Chip,
Label,
LabelGroup,
TextList,
TextListItem,
TextListItemVariants,
TextListVariants,
Tooltip,
} from '@patternfly/react-core';
import { InventoriesAPI, ConstructedInventoriesAPI } from 'api';
import { Inventory } from 'types';
import { formatDateString } from 'util/dates';
import { relatedResourceDeleteRequests } from 'util/getRelatedResourceDeleteDetails';
import useRequest, { useDismissableError } from 'hooks/useRequest';
import AlertModal from 'components/AlertModal';
import { CardBody, CardActionsRow } from 'components/Card';
import { DetailList, Detail, UserDateDetail } from 'components/DetailList';
import ChipGroup from 'components/ChipGroup';
import { VariablesDetail } from 'components/CodeEditor';
import DeleteButton from 'components/DeleteButton';
import ErrorDetail from 'components/ErrorDetail';
import ContentError from 'components/ContentError';
import ContentLoading from 'components/ContentLoading';
import ChipGroup from 'components/ChipGroup';
import Popover from 'components/Popover';
import { InventoriesAPI, ConstructedInventoriesAPI } from 'api';
import useRequest, { useDismissableError } from 'hooks/useRequest';
import { Inventory } from 'types';
import { relatedResourceDeleteRequests } from 'util/getRelatedResourceDeleteDetails';
import { DetailList, Detail, UserDateDetail } from 'components/DetailList';
import DeleteButton from 'components/DeleteButton';
import ErrorDetail from 'components/ErrorDetail';
import InstanceGroupLabels from 'components/InstanceGroupLabels';
import JobCancelButton from 'components/JobCancelButton';
import Popover from 'components/Popover';
import StatusLabel from 'components/StatusLabel';
import ConstructedInventorySyncButton from './ConstructedInventorySyncButton';
import useWsInventorySourcesDetails from '../shared/useWsInventorySourcesDetails';
import getHelpText from '../shared/Inventory.helptext';
function JobStatusLabel({ job }) {
if (!job) {
return null;
}
return (
<Tooltip
position="top"
content={
<>
<div>{t`MOST RECENT SYNC`}</div>
<div>
{t`JOB ID:`} {job.id}
</div>
<div>
{t`STATUS:`} {job.status.toUpperCase()}
</div>
{job.finished && (
<div>
{t`FINISHED:`} {formatDateString(job.finished)}
</div>
)}
</>
}
key={job.id}
>
<Link to={`/jobs/inventory/${job.id}`}>
<StatusLabel status={job.status} />
</Link>
</Tooltip>
);
}
function ConstructedInventoryDetail({ inventory }) {
const history = useHistory();
const helpText = getHelpText();
const {
result: { instanceGroups, sourceInventories, actions },
result: { instanceGroups, inputInventories, inventorySource, actions },
request: fetchRelatedDetails,
error: contentError,
isLoading,
} = useRequest(
useCallback(async () => {
const [response, sourceInvResponse, options] = await Promise.all([
const [
instanceGroupsResponse,
inputInventoriesResponse,
inventorySourceResponse,
optionsResponse,
] = await Promise.all([
InventoriesAPI.readInstanceGroups(inventory.id),
InventoriesAPI.readSourceInventories(inventory.id),
ConstructedInventoriesAPI.readOptions(inventory.id),
InventoriesAPI.readInputInventories(inventory.id),
InventoriesAPI.readSources(inventory.id),
ConstructedInventoriesAPI.readOptions(),
]);
return {
instanceGroups: response.data.results,
sourceInventories: sourceInvResponse.data.results,
actions: options.data.actions.GET,
instanceGroups: instanceGroupsResponse.data.results,
inputInventories: inputInventoriesResponse.data.results,
inventorySource: inventorySourceResponse.data.results[0],
actions: optionsResponse.data.actions.GET,
};
}, [inventory.id]),
{
instanceGroups: [],
sourceInventories: [],
inputInventories: [],
inventorySource: {},
actions: {},
isLoading: true,
}
@ -62,6 +111,16 @@ function ConstructedInventoryDetail({ inventory }) {
fetchRelatedDetails();
}, [fetchRelatedDetails]);
const wsInventorySource = useWsInventorySourcesDetails(inventorySource);
const inventorySourceSyncJob =
wsInventorySource.summary_fields?.current_job ||
wsInventorySource.summary_fields?.last_job ||
null;
const wsInventory = {
...inventory,
...wsInventorySource?.summary_fields?.inventory,
};
const { request: deleteInventory, error: deleteError } = useRequest(
useCallback(async () => {
await InventoriesAPI.destroy(inventory.id);
@ -71,9 +130,6 @@ function ConstructedInventoryDetail({ inventory }) {
const { error, dismissError } = useDismissableError(deleteError);
const { organization, user_capabilities: userCapabilities } =
inventory.summary_fields;
const deleteDetailsRequests =
relatedResourceDeleteRequests.inventory(inventory);
@ -93,6 +149,14 @@ function ConstructedInventoryDetail({ inventory }) {
value={inventory.name}
dataCy="constructed-inventory-name"
/>
<Detail
label={t`Last Job Status`}
value={
inventorySourceSyncJob && (
<JobStatusLabel job={inventorySourceSyncJob} />
)
}
/>
<Detail
label={t`Description`}
value={inventory.description}
@ -113,26 +177,28 @@ function ConstructedInventoryDetail({ inventory }) {
label={t`Organization`}
dataCy="constructed-inventory-organization"
value={
<Link to={`/organizations/${organization.id}/details`}>
{organization.name}
<Link
to={`/organizations/${inventory.summary_fields?.organization.id}/details`}
>
{inventory.summary_fields?.organization.name}
</Link>
}
/>
<Detail
label={actions.total_groups.label}
value={inventory.total_groups}
value={wsInventory.total_groups}
helpText={actions.total_groups.help_text}
dataCy="constructed-inventory-total-groups"
/>
<Detail
label={actions.total_hosts.label}
value={inventory.total_hosts}
value={wsInventory.total_hosts}
helpText={actions.total_hosts.help_text}
dataCy="constructed-inventory-total-hosts"
/>
<Detail
label={actions.total_inventory_sources.label}
value={inventory.total_inventory_sources}
value={wsInventory.total_inventory_sources}
helpText={actions.total_inventory_sources.help_text}
dataCy="constructed-inventory-sources"
/>
@ -144,7 +210,7 @@ function ConstructedInventoryDetail({ inventory }) {
/>
<Detail
label={actions.inventory_sources_with_failures.label}
value={inventory.inventory_sources_with_failures}
value={wsInventory.inventory_sources_with_failures}
helpText={actions.inventory_sources_with_failures.help_text}
dataCy="constructed-inventory-sources-with-failures"
/>
@ -204,26 +270,29 @@ function ConstructedInventoryDetail({ inventory }) {
/>
<Detail
fullWidth
label={t`Source Inventories`}
label={t`Input Inventories`}
value={
<ChipGroup
numChips={5}
totalChips={sourceInventories?.length}
ouiaId="source-inventory-chips"
>
{sourceInventories?.map((sourceInventory) => (
<Link
key={sourceInventory.id}
to={`/inventories/inventory/${sourceInventory.id}/details`}
<LabelGroup numLabels={5}>
{inputInventories?.map((inputInventory) => (
<Label
color="blue"
key={inputInventory.id}
render={({ className, content, componentRef }) => (
<Link
className={className}
innerRef={componentRef}
to={`/inventories/inventory/${inputInventory.id}/details`}
>
{content}
</Link>
)}
>
<Chip key={sourceInventory.id} isReadOnly>
{sourceInventory.name}
</Chip>
</Link>
{inputInventory.name}
</Label>
))}
</ChipGroup>
</LabelGroup>
}
isEmpty={sourceInventories?.length === 0}
isEmpty={inputInventories?.length === 0}
/>
<VariablesDetail
label={actions.source_vars.label}
@ -245,7 +314,7 @@ function ConstructedInventoryDetail({ inventory }) {
/>
</DetailList>
<CardActionsRow>
{userCapabilities.edit && (
{inventory?.summary_fields?.user_capabilities?.edit && (
<Button
ouiaId="inventory-detail-edit-button"
component={Link}
@ -254,7 +323,21 @@ function ConstructedInventoryDetail({ inventory }) {
{t`Edit`}
</Button>
)}
{userCapabilities.delete && (
{inventorySource?.summary_fields?.user_capabilities?.start &&
(['new', 'running', 'pending', 'waiting'].includes(
inventorySourceSyncJob?.status
) ? (
<JobCancelButton
job={{ id: inventorySourceSyncJob.id, type: 'inventory_update' }}
errorTitle={t`Constructed Inventory Source Sync Error`}
title={t`Cancel Constructed Inventory Source Sync`}
errorMessage={t`Failed to cancel Constructed Inventory Source Sync`}
buttonText={t`Cancel Sync`}
/>
) : (
<ConstructedInventorySyncButton inventoryId={inventory.id} />
))}
{inventory?.summary_fields?.user_capabilities?.delete && (
<DeleteButton
name={inventory.name}
modalTitle={t`Delete Inventory`}

View File

@ -1,8 +1,17 @@
import React from 'react';
import { act } from 'react-dom/test-utils';
import { InventoriesAPI, CredentialTypesAPI } from 'api';
import { mountWithContexts } from '../../../../testUtils/enzymeHelpers';
import { Router } from 'react-router-dom';
import { InventoriesAPI, ConstructedInventoriesAPI } from 'api';
import {
render,
screen,
waitForElementToBeRemoved,
} from '@testing-library/react';
import '@testing-library/jest-dom';
import { createMemoryHistory } from 'history';
import { I18nProvider } from '@lingui/react';
import { i18n } from '@lingui/core';
import { en } from 'make-plural/plurals';
import english from '../../../locales/en/messages';
import ConstructedInventoryDetail from './ConstructedInventoryDetail';
jest.mock('../../../api');
@ -30,6 +39,15 @@ const mockInventory = {
copy: true,
adhoc: true,
},
labels: {
count: 1,
results: [
{
id: 17,
name: 'seventeen',
},
],
},
},
created: '2019-10-04T16:56:48.025455Z',
modified: '2019-10-04T16:56:48.025468Z',
@ -46,21 +64,187 @@ const mockInventory = {
total_inventory_sources: 0,
inventory_sources_with_failures: 0,
pending_deletion: false,
prevent_instance_group_fallback: false,
prevent_instance_group_fallback: true,
update_cache_timeout: 0,
limit: '',
verbosity: 1,
source_vars:
'{\n "plugin": "constructed",\n "strict": true,\n "groups": {\n "shutdown": "resolved_state == \\"shutdown\\"",\n "shutdown_in_product_dev": "resolved_state == \\"shutdown\\" and account_alias == \\"product_dev\\""\n },\n "compose": {\n "resolved_state": "state | default(\\"running\\")"\n }\n}',
};
describe('<ConstructedInventoryDetail />', () => {
test('initially renders successfully', async () => {
let wrapper;
await act(async () => {
wrapper = mountWithContexts(
<ConstructedInventoryDetail inventory={mockInventory} />
);
const history = createMemoryHistory({
initialEntries: ['/inventories/constructed_inventory/1/details'],
});
const Component = (props) => (
<I18nProvider i18n={i18n}>
<Router history={history}>
<ConstructedInventoryDetail inventory={mockInventory} {...props} />
</Router>
</I18nProvider>
);
beforeEach(() => {
i18n.loadLocaleData({ en: { plurals: en } });
i18n.load({ en: english });
i18n.activate('en');
InventoriesAPI.readInstanceGroups.mockResolvedValue({
data: { results: [] },
});
expect(wrapper.length).toBe(1);
expect(wrapper.find('ConstructedInventoryDetail').length).toBe(1);
InventoriesAPI.readInputInventories.mockResolvedValue({
data: {
results: [
{
id: 123,
name: 'input_inventory_123',
},
{
id: 456,
name: 'input_inventory_456',
},
],
},
});
InventoriesAPI.readSources.mockResolvedValue({
data: {
results: [
{
id: 999,
type: 'inventory_source',
summary_fields: {
last_job: {
id: 101,
name: 'Auto-created source for: Constructed Inv',
status: 'successful',
finished: '2023-02-02T22:22:22.222220Z',
},
user_capabilities: {
start: true,
},
},
},
],
},
});
ConstructedInventoriesAPI.readOptions.mockResolvedValue({
data: {
related: {},
actions: {
GET: {
limit: {
label: 'Limit',
help_text: '',
},
total_groups: {
label: 'Total Groups',
help_text: '',
},
total_hosts: {
label: 'Total Hosts',
help_text: '',
},
total_inventory_sources: {
label: 'Total inventory sources',
help_text: '',
},
update_cache_timeout: {
label: 'Update cache timeout',
help_text: '',
},
inventory_sources_with_failures: {
label: 'Inventory sources with failures',
help_text: '',
},
source_vars: {
label: 'Source vars',
help_text: '',
},
verbosity: {
label: 'Verbosity',
help_text: '',
},
created: {
label: 'Created by',
help_text: '',
},
modified: {
label: 'Modified by',
help_text: '',
},
},
},
},
});
});
afterEach(() => {
jest.clearAllMocks();
});
test('should render details', async () => {
render(<Component />);
await waitForElementToBeRemoved(() => screen.getByRole('progressbar'));
expect(screen.getByText('Name')).toBeInTheDocument();
expect(screen.getByText('Constructed Inv')).toBeInTheDocument();
expect(screen.getByText('Last Job Status')).toBeInTheDocument();
expect(screen.getByText('Successful')).toBeInTheDocument();
expect(screen.getByText('Type')).toBeInTheDocument();
expect(screen.getByText('Constructed Inventory')).toBeInTheDocument();
});
test('should render action buttons', async () => {
render(<Component />);
await waitForElementToBeRemoved(() => screen.getByRole('progressbar'));
expect(screen.getByRole('link', { name: 'Edit' })).toHaveAttribute(
'href',
'/inventories/constructed_inventory/1/edit'
);
expect(
screen.getByRole('button', { name: 'Start inventory source sync' })
).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Delete' })).toBeInTheDocument();
});
test('should show cancel sync button during an inventory source sync running job', async () => {
InventoriesAPI.readSources.mockResolvedValue({
data: {
results: [
{
id: 999,
type: 'inventory_source',
summary_fields: {
current_job: {
id: 111,
name: 'Auto-created source for: Constructed Inv',
status: 'running',
},
user_capabilities: {
start: true,
},
},
},
],
},
});
render(<Component />);
await waitForElementToBeRemoved(() => screen.getByRole('progressbar'));
expect(
screen.getByRole('button', {
name: 'Cancel Constructed Inventory Source Sync',
})
).toBeInTheDocument();
});
test('should show error when the api throws while fetching details', async () => {
InventoriesAPI.readInputInventories.mockRejectedValueOnce(new Error());
render(<Component />);
await waitForElementToBeRemoved(() => screen.getByRole('progressbar'));
expect(
screen.getByText(
'There was an error loading this content. Please reload the page.'
)
).toBeInTheDocument();
});
});

View File

@ -0,0 +1,59 @@
import React, { useCallback } from 'react';
import { t } from '@lingui/macro';
import PropTypes from 'prop-types';
import { Button, Tooltip } from '@patternfly/react-core';
import useRequest, { useDismissableError } from 'hooks/useRequest';
import AlertModal from 'components/AlertModal/AlertModal';
import ErrorDetail from 'components/ErrorDetail/ErrorDetail';
import { InventoriesAPI } from 'api';
function ConstructedInventorySyncButton({ inventoryId }) {
const testId = `constructed-inventory-${inventoryId}-sync`;
const {
isLoading: startSyncLoading,
error: startSyncError,
request: startSyncProcess,
} = useRequest(
useCallback(
async () => InventoriesAPI.syncAllSources(inventoryId),
[inventoryId]
),
{}
);
const { error: startError, dismissError: dismissStartError } =
useDismissableError(startSyncError);
return (
<>
<Tooltip content={t`Start sync process`} position="top">
<Button
ouiaId={testId}
isDisabled={startSyncLoading}
aria-label={t`Start inventory source sync`}
variant="secondary"
onClick={startSyncProcess}
>
{t`Sync`}
</Button>
</Tooltip>
{startError && (
<AlertModal
isOpen={startError}
variant="error"
title={t`Error!`}
onClose={dismissStartError}
>
{t`Failed to sync constructed inventory source`}
<ErrorDetail error={startError} />
</AlertModal>
)}
</>
);
}
ConstructedInventorySyncButton.propTypes = {
inventoryId: PropTypes.number.isRequired,
};
export default ConstructedInventorySyncButton;

View File

@ -0,0 +1,41 @@
import React from 'react';
import { InventoriesAPI } from 'api';
import ConstructedInventorySyncButton from './ConstructedInventorySyncButton';
import { render, fireEvent, screen, waitFor } from '@testing-library/react';
import '@testing-library/jest-dom';
jest.mock('../../../api');
const inventory = { id: 100, name: 'Constructed Inventory' };
describe('<ConstructedInventorySyncButton />', () => {
const Component = () => (
<ConstructedInventorySyncButton inventoryId={inventory.id} />
);
test('should render start sync button', () => {
render(<Component />);
expect(
screen.getByRole('button', { name: 'Start inventory source sync' })
).toBeInTheDocument();
});
test('should make expected api request on sync', async () => {
render(<Component />);
const syncButton = screen.queryByText('Sync');
fireEvent.click(syncButton);
await waitFor(() =>
expect(InventoriesAPI.syncAllSources).toHaveBeenCalledWith(100)
);
});
test('should show alert modal on throw', async () => {
InventoriesAPI.syncAllSources.mockRejectedValueOnce(new Error());
render(<Component />);
await waitFor(() => {
const syncButton = screen.queryByText('Sync');
fireEvent.click(syncButton);
});
expect(screen.getByRole('dialog', { name: 'Alert modal Error!' }));
});
});

View File

@ -31,7 +31,7 @@ import { formatDateString } from 'util/dates';
import Popover from 'components/Popover';
import { VERBOSITY } from 'components/VerbositySelectField';
import InventorySourceSyncButton from '../shared/InventorySourceSyncButton';
import useWsInventorySourcesDetails from '../InventorySources/useWsInventorySourcesDetails';
import useWsInventorySourcesDetails from '../shared/useWsInventorySourcesDetails';
import getHelpText from '../shared/Inventory.helptext';
function InventorySourceDetail({ inventorySource }) {

View File

@ -1,42 +0,0 @@
import { useState, useEffect } from 'react';
import useWebsocket from 'hooks/useWebsocket';
export default function useWsInventorySourcesDetails(initialSources) {
const [sources, setSources] = useState(initialSources);
const lastMessage = useWebsocket({
jobs: ['status_changed'],
control: ['limit_reached_1'],
});
useEffect(() => {
setSources(initialSources);
}, [initialSources]);
useEffect(
() => {
if (
!lastMessage?.unified_job_id ||
!lastMessage?.inventory_source_id ||
lastMessage.type !== 'inventory_update'
) {
return;
}
const updateSource = {
...sources,
summary_fields: {
...sources.summary_fields,
current_job: {
id: lastMessage.unified_job_id,
status: lastMessage.status,
finished: lastMessage.finished,
},
},
};
setSources(updateSource);
},
[lastMessage] // eslint-disable-line react-hooks/exhaustive-deps
);
return sources;
}

View File

@ -0,0 +1,58 @@
import { useState, useEffect } from 'react';
import useWebsocket from 'hooks/useWebsocket';
import { InventorySourcesAPI } from 'api';
export default function useWsInventorySourcesDetails(initialSource) {
const [source, setSource] = useState(initialSource);
const lastMessage = useWebsocket({
jobs: ['status_changed'],
control: ['limit_reached_1'],
});
useEffect(() => {
setSource(initialSource);
}, [initialSource]);
useEffect(
() => {
if (
!lastMessage?.unified_job_id ||
!lastMessage?.inventory_source_id ||
lastMessage.type !== 'inventory_update'
) {
return;
}
if (
['successful', 'failed', 'error', 'cancelled'].includes(
lastMessage.status
)
) {
fetchSource();
}
setSource(updateSource(source, lastMessage));
},
[lastMessage] // eslint-disable-line react-hooks/exhaustive-deps
);
async function fetchSource() {
const { data } = await InventorySourcesAPI.readDetail(source.id);
setSource(data);
}
return source;
}
function updateSource(source, message) {
return {
...source,
summary_fields: {
...source.summary_fields,
current_job: {
id: message.unified_job_id,
status: message.status,
finished: message.finished,
},
},
};
}

View File

@ -1,9 +1,12 @@
import React from 'react';
import { act } from 'react-dom/test-utils';
import WS from 'jest-websocket-mock';
import { InventorySourcesAPI } from 'api';
import { mountWithContexts } from '../../../../testUtils/enzymeHelpers';
import useWsInventorySourceDetails from './useWsInventorySourcesDetails';
jest.mock('../../../api/models/InventorySources');
function TestInner() {
return <div />;
}
@ -111,6 +114,27 @@ describe('useWsProject', () => {
status: 'running',
finished: null,
});
expect(InventorySourcesAPI.readDetail).toHaveBeenCalledTimes(0);
InventorySourcesAPI.readDetail.mockResolvedValue({
data: {},
});
await act(async () => {
mockServer.send(
JSON.stringify({
group_name: 'jobs',
inventory_id: 1,
status: 'successful',
type: 'inventory_update',
unified_job_id: 2,
unified_job_template_id: 1,
inventory_source_id: 1,
})
);
});
expect(InventorySourcesAPI.readDetail).toHaveBeenCalledTimes(1);
jest.clearAllMocks();
WS.clean();
});
});