mirror of
https://github.com/ansible/awx.git
synced 2026-03-20 02:17:37 -02:30
Adds functionality to deprovision an instance from list and details view
This commit is contained in:
committed by
Jeff Bradberry
parent
f3a9d4db07
commit
25afb8477e
@@ -25,6 +25,12 @@ class Instances extends Base {
|
|||||||
readInstanceGroup(instanceId) {
|
readInstanceGroup(instanceId) {
|
||||||
return this.http.get(`${this.baseUrl}${instanceId}/instance_groups/`);
|
return this.http.get(`${this.baseUrl}${instanceId}/instance_groups/`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
deprovisionInstance(instanceId) {
|
||||||
|
return this.http.post(`${this.baseUrl}${instanceId}`, {
|
||||||
|
node_state: 'deprovisioning',
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default Instances;
|
export default Instances;
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ function Instance({ setBreadcrumb }) {
|
|||||||
<Switch>
|
<Switch>
|
||||||
<Redirect from="/instances/:id" to="/instances/:id/details" exact />
|
<Redirect from="/instances/:id" to="/instances/:id/details" exact />
|
||||||
<Route path="/instances/:id/details" key="details">
|
<Route path="/instances/:id/details" key="details">
|
||||||
<InstanceDetail setBreadcrumb={setBreadcrumb} />
|
<InstanceDetail isK8s={isK8s} setBreadcrumb={setBreadcrumb} />
|
||||||
</Route>
|
</Route>
|
||||||
{isK8s && (
|
{isK8s && (
|
||||||
<Route path="/instances/:id/peers" key="peers">
|
<Route path="/instances/:id/peers" key="peers">
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { useCallback, useEffect, useState } from 'react';
|
import React, { useCallback, useEffect, useState } from 'react';
|
||||||
|
|
||||||
import { Link, useParams } from 'react-router-dom';
|
import { Link, useHistory, useParams } from 'react-router-dom';
|
||||||
import { t, Plural } from '@lingui/macro';
|
import { t, Plural } from '@lingui/macro';
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
@@ -28,7 +28,11 @@ import ContentError from 'components/ContentError';
|
|||||||
import ContentLoading from 'components/ContentLoading';
|
import ContentLoading from 'components/ContentLoading';
|
||||||
import { Detail, DetailList } from 'components/DetailList';
|
import { Detail, DetailList } from 'components/DetailList';
|
||||||
import StatusLabel from 'components/StatusLabel';
|
import StatusLabel from 'components/StatusLabel';
|
||||||
import useRequest, { useDismissableError } from 'hooks/useRequest';
|
import useRequest, {
|
||||||
|
useDeleteItems,
|
||||||
|
useDismissableError,
|
||||||
|
} from 'hooks/useRequest';
|
||||||
|
import RemoveInstanceButton from '../Shared/RemoveInstanceButton';
|
||||||
|
|
||||||
const Unavailable = styled.span`
|
const Unavailable = styled.span`
|
||||||
color: var(--pf-global--danger-color--200);
|
color: var(--pf-global--danger-color--200);
|
||||||
@@ -56,11 +60,11 @@ function computeForks(memCapacity, cpuCapacity, selectedCapacityAdjustment) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function InstanceDetail({ setBreadcrumb }) {
|
function InstanceDetail({ setBreadcrumb, isK8s }) {
|
||||||
const { me = {} } = useConfig();
|
const { me = {} } = useConfig();
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
const [forks, setForks] = useState();
|
const [forks, setForks] = useState();
|
||||||
|
const history = useHistory();
|
||||||
const [healthCheck, setHealthCheck] = useState({});
|
const [healthCheck, setHealthCheck] = useState({});
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -148,10 +152,25 @@ function InstanceDetail({ setBreadcrumb }) {
|
|||||||
const { error, dismissError } = useDismissableError(
|
const { error, dismissError } = useDismissableError(
|
||||||
updateInstanceError || healthCheckError
|
updateInstanceError || healthCheckError
|
||||||
);
|
);
|
||||||
|
const {
|
||||||
|
isLoading: isRemoveLoading,
|
||||||
|
deleteItems: removeInstances,
|
||||||
|
deletionError: removeError,
|
||||||
|
clearDeletionError,
|
||||||
|
} = useDeleteItems(
|
||||||
|
async () => {
|
||||||
|
await InstancesAPI.deprovisionInstance(instance.id);
|
||||||
|
history.push('/instances');
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fetchItems: fetchDetails,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
if (contentError) {
|
if (contentError) {
|
||||||
return <ContentError error={contentError} />;
|
return <ContentError error={contentError} />;
|
||||||
}
|
}
|
||||||
if (isLoading) {
|
if (isLoading || isRemoveLoading) {
|
||||||
return <ContentLoading />;
|
return <ContentLoading />;
|
||||||
}
|
}
|
||||||
const isHopNode = instance.node_type === 'hop';
|
const isHopNode = instance.node_type === 'hop';
|
||||||
@@ -218,6 +237,7 @@ function InstanceDetail({ setBreadcrumb }) {
|
|||||||
<Tooltip content={t`Click to download bundle`}>
|
<Tooltip content={t`Click to download bundle`}>
|
||||||
<Button
|
<Button
|
||||||
component="a"
|
component="a"
|
||||||
|
isSmall
|
||||||
href={`${instance.related?.install_bundle}`}
|
href={`${instance.related?.install_bundle}`}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
@@ -286,6 +306,13 @@ function InstanceDetail({ setBreadcrumb }) {
|
|||||||
</DetailList>
|
</DetailList>
|
||||||
{!isHopNode && (
|
{!isHopNode && (
|
||||||
<CardActionsRow>
|
<CardActionsRow>
|
||||||
|
{me.is_superuser && isK8s && instance.node_type === 'execution' && (
|
||||||
|
<RemoveInstanceButton
|
||||||
|
itemsToRemove={[instance]}
|
||||||
|
isK8s={isK8s}
|
||||||
|
onRemove={removeInstances}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<Tooltip content={t`Run a health check on the instance`}>
|
<Tooltip content={t`Run a health check on the instance`}>
|
||||||
<Button
|
<Button
|
||||||
isDisabled={!me.is_superuser || isRunningHealthCheck}
|
isDisabled={!me.is_superuser || isRunningHealthCheck}
|
||||||
@@ -319,6 +346,19 @@ function InstanceDetail({ setBreadcrumb }) {
|
|||||||
<ErrorDetail error={error} />
|
<ErrorDetail error={error} />
|
||||||
</AlertModal>
|
</AlertModal>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{removeError && (
|
||||||
|
<AlertModal
|
||||||
|
isOpen={removeError}
|
||||||
|
variant="error"
|
||||||
|
aria-label={t`Removal Error`}
|
||||||
|
title={t`Error!`}
|
||||||
|
onClose={clearDeletionError}
|
||||||
|
>
|
||||||
|
{t`Failed to remove one or more instances.`}
|
||||||
|
<ErrorDetail error={removeError} />
|
||||||
|
</AlertModal>
|
||||||
|
)}
|
||||||
</CardBody>
|
</CardBody>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,12 +15,16 @@ import PaginatedTable, {
|
|||||||
import AlertModal from 'components/AlertModal';
|
import AlertModal from 'components/AlertModal';
|
||||||
import ErrorDetail from 'components/ErrorDetail';
|
import ErrorDetail from 'components/ErrorDetail';
|
||||||
import { useConfig } from 'contexts/Config';
|
import { useConfig } from 'contexts/Config';
|
||||||
import useRequest, { useDismissableError } from 'hooks/useRequest';
|
import useRequest, {
|
||||||
|
useDismissableError,
|
||||||
|
useDeleteItems,
|
||||||
|
} from 'hooks/useRequest';
|
||||||
import useSelected from 'hooks/useSelected';
|
import useSelected from 'hooks/useSelected';
|
||||||
import { InstancesAPI, SettingsAPI } from 'api';
|
import { InstancesAPI, SettingsAPI } from 'api';
|
||||||
import { getQSConfig, parseQueryString } from 'util/qs';
|
import { getQSConfig, parseQueryString } from 'util/qs';
|
||||||
import HealthCheckButton from 'components/HealthCheckButton';
|
import HealthCheckButton from 'components/HealthCheckButton';
|
||||||
import InstanceListItem from './InstanceListItem';
|
import InstanceListItem from './InstanceListItem';
|
||||||
|
import RemoveInstanceButton from '../Shared/RemoveInstanceButton';
|
||||||
|
|
||||||
const QS_CONFIG = getQSConfig('instance', {
|
const QS_CONFIG = getQSConfig('instance', {
|
||||||
page: 1,
|
page: 1,
|
||||||
@@ -66,13 +70,13 @@ function InstanceList() {
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
const { selected, isAllSelected, handleSelect, clearSelected, selectAll } =
|
|
||||||
useSelected(instances.filter((i) => i.node_type !== 'hop'));
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchInstances();
|
fetchInstances();
|
||||||
}, [fetchInstances]);
|
}, [fetchInstances]);
|
||||||
|
|
||||||
|
const { selected, isAllSelected, handleSelect, clearSelected, selectAll } =
|
||||||
|
useSelected(instances.filter((i) => i.node_type !== 'hop'));
|
||||||
|
|
||||||
const {
|
const {
|
||||||
error: healthCheckError,
|
error: healthCheckError,
|
||||||
request: fetchHealthCheck,
|
request: fetchHealthCheck,
|
||||||
@@ -96,13 +100,28 @@ function InstanceList() {
|
|||||||
const { expanded, isAllExpanded, handleExpand, expandAll } =
|
const { expanded, isAllExpanded, handleExpand, expandAll } =
|
||||||
useExpanded(instances);
|
useExpanded(instances);
|
||||||
|
|
||||||
|
const {
|
||||||
|
isLoading: isRemoveLoading,
|
||||||
|
deleteItems: handleRemoveInstances,
|
||||||
|
deletionError: removeError,
|
||||||
|
clearDeletionError,
|
||||||
|
} = useDeleteItems(
|
||||||
|
() =>
|
||||||
|
Promise.all(
|
||||||
|
selected.map(({ id }) => InstancesAPI.deprovisionInstance(id))
|
||||||
|
),
|
||||||
|
{ fetchItems: fetchInstances }
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageSection>
|
<PageSection>
|
||||||
<Card>
|
<Card>
|
||||||
<PaginatedTable
|
<PaginatedTable
|
||||||
contentError={contentError}
|
contentError={contentError || removeError}
|
||||||
hasContentLoading={isLoading || isHealthCheckLoading}
|
hasContentLoading={
|
||||||
|
isLoading || isHealthCheckLoading || isRemoveLoading
|
||||||
|
}
|
||||||
items={instances}
|
items={instances}
|
||||||
itemCount={count}
|
itemCount={count}
|
||||||
pluralizedItemName={t`Instances`}
|
pluralizedItemName={t`Instances`}
|
||||||
@@ -149,10 +168,17 @@ function InstanceList() {
|
|||||||
key="add"
|
key="add"
|
||||||
linkTo="/instances/add"
|
linkTo="/instances/add"
|
||||||
/>,
|
/>,
|
||||||
|
<RemoveInstanceButton
|
||||||
|
itemsToRemove={selected}
|
||||||
|
isK8s={isK8s}
|
||||||
|
key="remove"
|
||||||
|
onRemove={handleRemoveInstances}
|
||||||
|
/>,
|
||||||
]
|
]
|
||||||
: []),
|
: []),
|
||||||
<HealthCheckButton
|
<HealthCheckButton
|
||||||
onClick={handleHealthCheck}
|
onClick={handleHealthCheck}
|
||||||
|
key="healthCheck"
|
||||||
selectedItems={selected}
|
selectedItems={selected}
|
||||||
/>,
|
/>,
|
||||||
]}
|
]}
|
||||||
@@ -198,6 +224,18 @@ function InstanceList() {
|
|||||||
<ErrorDetail error={error} />
|
<ErrorDetail error={error} />
|
||||||
</AlertModal>
|
</AlertModal>
|
||||||
)}
|
)}
|
||||||
|
{removeError && (
|
||||||
|
<AlertModal
|
||||||
|
isOpen={removeError}
|
||||||
|
variant="error"
|
||||||
|
aria-label={t`Removal Error`}
|
||||||
|
title={t`Error!`}
|
||||||
|
onClose={clearDeletionError}
|
||||||
|
>
|
||||||
|
{t`Failed to remove one or more instances.`}
|
||||||
|
<ErrorDetail error={removeError} />
|
||||||
|
</AlertModal>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
198
awx/ui/src/screens/Instances/Shared/RemoveInstanceButton.js
Normal file
198
awx/ui/src/screens/Instances/Shared/RemoveInstanceButton.js
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
import React, { useContext, useState, useEffect } from 'react';
|
||||||
|
import { t, Plural } from '@lingui/macro';
|
||||||
|
import { KebabifiedContext } from 'contexts/Kebabified';
|
||||||
|
import {
|
||||||
|
getRelatedResourceDeleteCounts,
|
||||||
|
relatedResourceDeleteRequests,
|
||||||
|
} from 'util/getRelatedResourceDeleteDetails';
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
DropdownItem,
|
||||||
|
Tooltip,
|
||||||
|
Alert,
|
||||||
|
Badge,
|
||||||
|
} from '@patternfly/react-core';
|
||||||
|
import AlertModal from 'components/AlertModal';
|
||||||
|
import styled from 'styled-components';
|
||||||
|
import ErrorDetail from 'components/ErrorDetail';
|
||||||
|
|
||||||
|
const WarningMessage = styled(Alert)`
|
||||||
|
margin-top: 10px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Label = styled.span`
|
||||||
|
&& {
|
||||||
|
margin-right: 10px;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
function RemoveInstanceButton({ itemsToRemove, onRemove, isK8s }) {
|
||||||
|
const { isKebabified, onKebabModalChange } = useContext(KebabifiedContext);
|
||||||
|
const [removeMessageError, setRemoveMessageError] = useState(null);
|
||||||
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
|
const [removeDetails, setRemoveDetails] = useState(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
const cannotRemove = (item) => item.node_type !== 'execution';
|
||||||
|
|
||||||
|
const toggleModal = async (isOpen) => {
|
||||||
|
setRemoveDetails(null);
|
||||||
|
setIsLoading(true);
|
||||||
|
if (isOpen && itemsToRemove.length === 1) {
|
||||||
|
const { results, error } = await getRelatedResourceDeleteCounts(
|
||||||
|
relatedResourceDeleteRequests.instance(itemsToRemove[0])
|
||||||
|
);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
setRemoveMessageError(error);
|
||||||
|
} else {
|
||||||
|
setRemoveDetails(results);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setIsModalOpen(isOpen);
|
||||||
|
setIsLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemove = async () => {
|
||||||
|
await onRemove();
|
||||||
|
toggleModal(false);
|
||||||
|
};
|
||||||
|
useEffect(() => {
|
||||||
|
if (isKebabified) {
|
||||||
|
onKebabModalChange(isModalOpen);
|
||||||
|
}
|
||||||
|
}, [isKebabified, isModalOpen, onKebabModalChange]);
|
||||||
|
|
||||||
|
const renderTooltip = () => {
|
||||||
|
const itemsUnableToremove = itemsToRemove
|
||||||
|
.filter(cannotRemove)
|
||||||
|
.map((item) => item.hostname)
|
||||||
|
.join(', ');
|
||||||
|
if (itemsToRemove.some(cannotRemove)) {
|
||||||
|
return t`You do not have permission to remove instances: ${itemsUnableToremove}`;
|
||||||
|
}
|
||||||
|
if (itemsToRemove.length) {
|
||||||
|
return t`Remove`;
|
||||||
|
}
|
||||||
|
return t`Select a row to remove`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isDisabled =
|
||||||
|
itemsToRemove.length === 0 || itemsToRemove.some(cannotRemove);
|
||||||
|
|
||||||
|
const buildRemoveWarning = () => (
|
||||||
|
<div>
|
||||||
|
<Plural
|
||||||
|
value={itemsToRemove.length}
|
||||||
|
one="This intance is currently being used by other resources. Are you sure you want to delete it?"
|
||||||
|
other="Deleting these instances could impact other resources that rely on them. Are you sure you want to delete anyway?"
|
||||||
|
/>
|
||||||
|
{removeDetails &&
|
||||||
|
Object.entries(removeDetails).map(([key, value]) => (
|
||||||
|
<div key={key} aria-label={`${key}: ${value}`}>
|
||||||
|
<Label>{key}</Label>
|
||||||
|
<Badge>{value}</Badge>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (removeMessageError) {
|
||||||
|
return (
|
||||||
|
<AlertModal
|
||||||
|
isOpen={removeMessageError}
|
||||||
|
title={t`Error!`}
|
||||||
|
onClose={() => {
|
||||||
|
toggleModal(false);
|
||||||
|
setRemoveMessageError();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ErrorDetail error={removeMessageError} />
|
||||||
|
</AlertModal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{isKebabified ? (
|
||||||
|
<Tooltip content={renderTooltip()} position="top">
|
||||||
|
<DropdownItem
|
||||||
|
key="add"
|
||||||
|
isDisabled={isDisabled || !isK8s}
|
||||||
|
isLoading={isLoading}
|
||||||
|
ouiaId="remove-button"
|
||||||
|
spinnerAriaValueText={isLoading ? 'Loading' : undefined}
|
||||||
|
component="button"
|
||||||
|
onClick={() => {
|
||||||
|
toggleModal(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t`Remove`}
|
||||||
|
</DropdownItem>
|
||||||
|
</Tooltip>
|
||||||
|
) : (
|
||||||
|
<Tooltip content={renderTooltip()} position="top">
|
||||||
|
<div>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
isLoading={isLoading}
|
||||||
|
ouiaId="remove-button"
|
||||||
|
spinnerAriaValueText={isLoading ? 'Loading' : undefined}
|
||||||
|
onClick={() => toggleModal(true)}
|
||||||
|
isDisabled={isDisabled || !isK8s}
|
||||||
|
>
|
||||||
|
{t`Remove`}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isModalOpen && (
|
||||||
|
<AlertModal
|
||||||
|
variant="danger"
|
||||||
|
title={t`Remove Instances`}
|
||||||
|
isOpen={isModalOpen}
|
||||||
|
onClose={() => toggleModal(false)}
|
||||||
|
actions={[
|
||||||
|
<Button
|
||||||
|
ouiaId="remove-modal-confirm"
|
||||||
|
key="remove"
|
||||||
|
variant="danger"
|
||||||
|
aria-label={t`Confirm remove`}
|
||||||
|
onClick={handleRemove}
|
||||||
|
>
|
||||||
|
{t`Remove`}
|
||||||
|
</Button>,
|
||||||
|
<Button
|
||||||
|
ouiaId="remove-cancel"
|
||||||
|
key="cancel"
|
||||||
|
variant="link"
|
||||||
|
aria-label={t`cancel remove`}
|
||||||
|
onClick={() => {
|
||||||
|
toggleModal(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t`Cancel`}
|
||||||
|
</Button>,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<div>{t`This action will remove the following instances:`}</div>
|
||||||
|
{itemsToRemove.map((item) => (
|
||||||
|
<span key={item.id} id={`item-to-be-removed-${item.id}`}>
|
||||||
|
<strong>{item.hostname}</strong>
|
||||||
|
<br />
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
{removeDetails && (
|
||||||
|
<WarningMessage
|
||||||
|
variant="warning"
|
||||||
|
isInline
|
||||||
|
title={buildRemoveWarning()}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</AlertModal>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default RemoveInstanceButton;
|
||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
ExecutionEnvironmentsAPI,
|
ExecutionEnvironmentsAPI,
|
||||||
ApplicationsAPI,
|
ApplicationsAPI,
|
||||||
OrganizationsAPI,
|
OrganizationsAPI,
|
||||||
|
InstanceGroupsAPI,
|
||||||
} from 'api';
|
} from 'api';
|
||||||
|
|
||||||
export async function getRelatedResourceDeleteCounts(requests) {
|
export async function getRelatedResourceDeleteCounts(requests) {
|
||||||
@@ -274,4 +275,11 @@ export const relatedResourceDeleteRequests = {
|
|||||||
label: t`Templates`,
|
label: t`Templates`,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|
||||||
|
instance: (selected) => [
|
||||||
|
{
|
||||||
|
request: () => InstanceGroupsAPI.read({ instances: selected.id }),
|
||||||
|
label: t`Instance Groups`,
|
||||||
|
},
|
||||||
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user