mirror of
https://github.com/ansible/awx.git
synced 2026-01-11 01:57:35 -03:30
Adds functionality to deprovision an instance from list and details view
This commit is contained in:
parent
f3a9d4db07
commit
25afb8477e
@ -25,6 +25,12 @@ class Instances extends Base {
|
||||
readInstanceGroup(instanceId) {
|
||||
return this.http.get(`${this.baseUrl}${instanceId}/instance_groups/`);
|
||||
}
|
||||
|
||||
deprovisionInstance(instanceId) {
|
||||
return this.http.post(`${this.baseUrl}${instanceId}`, {
|
||||
node_state: 'deprovisioning',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default Instances;
|
||||
|
||||
@ -64,7 +64,7 @@ function Instance({ setBreadcrumb }) {
|
||||
<Switch>
|
||||
<Redirect from="/instances/:id" to="/instances/:id/details" exact />
|
||||
<Route path="/instances/:id/details" key="details">
|
||||
<InstanceDetail setBreadcrumb={setBreadcrumb} />
|
||||
<InstanceDetail isK8s={isK8s} setBreadcrumb={setBreadcrumb} />
|
||||
</Route>
|
||||
{isK8s && (
|
||||
<Route path="/instances/:id/peers" key="peers">
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
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 {
|
||||
Button,
|
||||
@ -28,7 +28,11 @@ import ContentError from 'components/ContentError';
|
||||
import ContentLoading from 'components/ContentLoading';
|
||||
import { Detail, DetailList } from 'components/DetailList';
|
||||
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`
|
||||
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 { id } = useParams();
|
||||
const [forks, setForks] = useState();
|
||||
|
||||
const history = useHistory();
|
||||
const [healthCheck, setHealthCheck] = useState({});
|
||||
|
||||
const {
|
||||
@ -148,10 +152,25 @@ function InstanceDetail({ setBreadcrumb }) {
|
||||
const { error, dismissError } = useDismissableError(
|
||||
updateInstanceError || healthCheckError
|
||||
);
|
||||
const {
|
||||
isLoading: isRemoveLoading,
|
||||
deleteItems: removeInstances,
|
||||
deletionError: removeError,
|
||||
clearDeletionError,
|
||||
} = useDeleteItems(
|
||||
async () => {
|
||||
await InstancesAPI.deprovisionInstance(instance.id);
|
||||
history.push('/instances');
|
||||
},
|
||||
{
|
||||
fetchItems: fetchDetails,
|
||||
}
|
||||
);
|
||||
|
||||
if (contentError) {
|
||||
return <ContentError error={contentError} />;
|
||||
}
|
||||
if (isLoading) {
|
||||
if (isLoading || isRemoveLoading) {
|
||||
return <ContentLoading />;
|
||||
}
|
||||
const isHopNode = instance.node_type === 'hop';
|
||||
@ -218,6 +237,7 @@ function InstanceDetail({ setBreadcrumb }) {
|
||||
<Tooltip content={t`Click to download bundle`}>
|
||||
<Button
|
||||
component="a"
|
||||
isSmall
|
||||
href={`${instance.related?.install_bundle}`}
|
||||
target="_blank"
|
||||
variant="secondary"
|
||||
@ -286,6 +306,13 @@ function InstanceDetail({ setBreadcrumb }) {
|
||||
</DetailList>
|
||||
{!isHopNode && (
|
||||
<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`}>
|
||||
<Button
|
||||
isDisabled={!me.is_superuser || isRunningHealthCheck}
|
||||
@ -319,6 +346,19 @@ function InstanceDetail({ setBreadcrumb }) {
|
||||
<ErrorDetail error={error} />
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -15,12 +15,16 @@ import PaginatedTable, {
|
||||
import AlertModal from 'components/AlertModal';
|
||||
import ErrorDetail from 'components/ErrorDetail';
|
||||
import { useConfig } from 'contexts/Config';
|
||||
import useRequest, { useDismissableError } from 'hooks/useRequest';
|
||||
import useRequest, {
|
||||
useDismissableError,
|
||||
useDeleteItems,
|
||||
} from 'hooks/useRequest';
|
||||
import useSelected from 'hooks/useSelected';
|
||||
import { InstancesAPI, SettingsAPI } from 'api';
|
||||
import { getQSConfig, parseQueryString } from 'util/qs';
|
||||
import HealthCheckButton from 'components/HealthCheckButton';
|
||||
import InstanceListItem from './InstanceListItem';
|
||||
import RemoveInstanceButton from '../Shared/RemoveInstanceButton';
|
||||
|
||||
const QS_CONFIG = getQSConfig('instance', {
|
||||
page: 1,
|
||||
@ -66,13 +70,13 @@ function InstanceList() {
|
||||
}
|
||||
);
|
||||
|
||||
const { selected, isAllSelected, handleSelect, clearSelected, selectAll } =
|
||||
useSelected(instances.filter((i) => i.node_type !== 'hop'));
|
||||
|
||||
useEffect(() => {
|
||||
fetchInstances();
|
||||
}, [fetchInstances]);
|
||||
|
||||
const { selected, isAllSelected, handleSelect, clearSelected, selectAll } =
|
||||
useSelected(instances.filter((i) => i.node_type !== 'hop'));
|
||||
|
||||
const {
|
||||
error: healthCheckError,
|
||||
request: fetchHealthCheck,
|
||||
@ -96,13 +100,28 @@ function InstanceList() {
|
||||
const { expanded, isAllExpanded, handleExpand, expandAll } =
|
||||
useExpanded(instances);
|
||||
|
||||
const {
|
||||
isLoading: isRemoveLoading,
|
||||
deleteItems: handleRemoveInstances,
|
||||
deletionError: removeError,
|
||||
clearDeletionError,
|
||||
} = useDeleteItems(
|
||||
() =>
|
||||
Promise.all(
|
||||
selected.map(({ id }) => InstancesAPI.deprovisionInstance(id))
|
||||
),
|
||||
{ fetchItems: fetchInstances }
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageSection>
|
||||
<Card>
|
||||
<PaginatedTable
|
||||
contentError={contentError}
|
||||
hasContentLoading={isLoading || isHealthCheckLoading}
|
||||
contentError={contentError || removeError}
|
||||
hasContentLoading={
|
||||
isLoading || isHealthCheckLoading || isRemoveLoading
|
||||
}
|
||||
items={instances}
|
||||
itemCount={count}
|
||||
pluralizedItemName={t`Instances`}
|
||||
@ -149,10 +168,17 @@ function InstanceList() {
|
||||
key="add"
|
||||
linkTo="/instances/add"
|
||||
/>,
|
||||
<RemoveInstanceButton
|
||||
itemsToRemove={selected}
|
||||
isK8s={isK8s}
|
||||
key="remove"
|
||||
onRemove={handleRemoveInstances}
|
||||
/>,
|
||||
]
|
||||
: []),
|
||||
<HealthCheckButton
|
||||
onClick={handleHealthCheck}
|
||||
key="healthCheck"
|
||||
selectedItems={selected}
|
||||
/>,
|
||||
]}
|
||||
@ -198,6 +224,18 @@ function InstanceList() {
|
||||
<ErrorDetail error={error} />
|
||||
</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,
|
||||
ApplicationsAPI,
|
||||
OrganizationsAPI,
|
||||
InstanceGroupsAPI,
|
||||
} from 'api';
|
||||
|
||||
export async function getRelatedResourceDeleteCounts(requests) {
|
||||
@ -274,4 +275,11 @@ export const relatedResourceDeleteRequests = {
|
||||
label: t`Templates`,
|
||||
},
|
||||
],
|
||||
|
||||
instance: (selected) => [
|
||||
{
|
||||
request: () => InstanceGroupsAPI.read({ instances: selected.id }),
|
||||
label: t`Instance Groups`,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user