Adds functionality to deprovision an instance from list and details view

This commit is contained in:
Alex Corey 2022-08-30 11:09:15 -04:00 committed by Jeff Bradberry
parent f3a9d4db07
commit 25afb8477e
6 changed files with 302 additions and 12 deletions

View File

@ -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;

View File

@ -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">

View File

@ -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>
);
}

View File

@ -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>
)}
</>
);
}

View 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;

View File

@ -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`,
},
],
};