Update UI to support pending health checks.

This commit is contained in:
Kia Lam 2022-10-10 19:45:46 -07:00
parent 5b5aac675b
commit 04b814cfd8
13 changed files with 237 additions and 65 deletions

View File

@ -3,7 +3,12 @@ import { Plural, t } from '@lingui/macro';
import { Button, DropdownItem, Tooltip } from '@patternfly/react-core';
import { useKebabifiedMenu } from 'contexts/Kebabified';
function HealthCheckButton({ isDisabled, onClick, selectedItems }) {
function HealthCheckButton({
isDisabled,
onClick,
selectedItems,
healthCheckPending,
}) {
const { isKebabified } = useKebabifiedMenu();
const selectedItemsCount = selectedItems.length;
@ -42,7 +47,11 @@ function HealthCheckButton({ isDisabled, onClick, selectedItems }) {
variant="secondary"
ouiaId="health-check"
onClick={onClick}
>{t`Run health check`}</Button>
isLoading={healthCheckPending}
spinnerAriaLabel={t`Running health check`}
>
{healthCheckPending ? t`Running health check` : t`Run health check`}
</Button>
</div>
</Tooltip>
);

View File

@ -12,7 +12,7 @@ import {
Tooltip,
Slider,
} from '@patternfly/react-core';
import { CaretLeftIcon } from '@patternfly/react-icons';
import { CaretLeftIcon, OutlinedClockIcon } from '@patternfly/react-icons';
import styled from 'styled-components';
import { useConfig } from 'contexts/Config';
@ -115,15 +115,9 @@ function InstanceDetails({ setBreadcrumb, instanceGroup }) {
useEffect(() => {
fetchDetails();
}, [fetchDetails]);
const {
error: healthCheckError,
isLoading: isRunningHealthCheck,
request: fetchHealthCheck,
} = useRequest(
const { error: healthCheckError, request: fetchHealthCheck } = useRequest(
useCallback(async () => {
const { status } = await InstancesAPI.healthCheck(instanceId);
const { data } = await InstancesAPI.readHealthCheckDetail(instanceId);
setHealthCheck(data);
if (status === 200) {
setShowHealthCheckAlert(true);
}
@ -161,6 +155,18 @@ function InstanceDetails({ setBreadcrumb, instanceGroup }) {
debounceUpdateInstance({ capacity_adjustment: roundedValue });
};
const formatHealthCheckTimeStamp = (last) => (
<>
{formatDateString(last)}
{instance.health_check_pending ? (
<>
{' '}
<OutlinedClockIcon />
</>
) : null}
</>
);
const { error, dismissError } = useDismissableError(
disassociateError || updateInstanceError || healthCheckError
);
@ -189,6 +195,8 @@ function InstanceDetails({ setBreadcrumb, instanceGroup }) {
return <ContentLoading />;
}
const isExecutionNode = instance.node_type === 'execution';
return (
<>
<RoutedTabs tabsArray={tabsArray} />
@ -218,7 +226,8 @@ function InstanceDetails({ setBreadcrumb, instanceGroup }) {
<Detail label={t`Total Jobs`} value={instance.jobs_total} />
<Detail
label={t`Last Health Check`}
value={formatDateString(healthCheck?.last_health_check)}
helpText={t`Health checks are asynchronous tasks. See the docs for more details.`}
value={formatHealthCheckTimeStamp(instance.last_health_check)}
/>
<Detail label={t`Node Type`} value={instance.node_type} />
<Detail
@ -274,18 +283,22 @@ function InstanceDetails({ setBreadcrumb, instanceGroup }) {
)}
</DetailList>
<CardActionsRow>
<Tooltip content={t`Run a health check on the instance`}>
<Button
isDisabled={!me.is_superuser || isRunningHealthCheck}
variant="primary"
ouiaId="health-check-button"
onClick={fetchHealthCheck}
isLoading={isRunningHealthCheck}
spinnerAriaLabel={t`Running health check`}
>
{t`Run health check`}
</Button>
</Tooltip>
{isExecutionNode && (
<Tooltip content={t`Run a health check on the instance`}>
<Button
isDisabled={!me.is_superuser || instance.health_check_pending}
variant="primary"
ouiaId="health-check-button"
onClick={fetchHealthCheck}
isLoading={instance.health_check_pending}
spinnerAriaLabel={t`Running health check`}
>
{instance.health_check_pending
? t`Running health check`
: t`Run health check`}
</Button>
</Tooltip>
)}
{me.is_superuser && instance.node_type !== 'control' && (
<DisassociateButton
verifyCannotDisassociate={instanceGroup.name === 'controlplane'}

View File

@ -87,8 +87,9 @@ describe('<InstanceDetails/>', () => {
mem_capacity: 38,
enabled: true,
managed_by_policy: true,
node_type: 'hybrid',
node_type: 'execution',
node_state: 'ready',
health_check_pending: false,
},
});
InstancesAPI.readHealthCheckDetail.mockResolvedValue({
@ -347,6 +348,67 @@ describe('<InstanceDetails/>', () => {
expect(wrapper.find('ErrorDetail')).toHaveLength(1);
});
test.each([
[1, 'hybrid', 0],
[2, 'hop', 0],
[3, 'control', 0],
])(
'hide health check button for non-execution type nodes',
async (a, b, expected) => {
InstancesAPI.readDetail.mockResolvedValue({
data: {
id: a,
type: 'instance',
url: '/api/v2/instances/1/',
related: {
named_url: '/api/v2/instances/awx_1/',
jobs: '/api/v2/instances/1/jobs/',
instance_groups: '/api/v2/instances/1/instance_groups/',
health_check: '/api/v2/instances/1/health_check/',
},
uuid: '00000000-0000-0000-0000-000000000000',
hostname: 'awx_1',
created: '2021-09-08T17:10:34.484569Z',
modified: '2021-09-09T13:55:44.219900Z',
last_seen: '2021-09-09T20:20:31.623148Z',
last_health_check: '2021-09-09T20:20:31.623148Z',
errors: '',
capacity_adjustment: '1.00',
version: '19.1.0',
capacity: 38,
consumed_capacity: 0,
percent_capacity_remaining: 100.0,
jobs_running: 0,
jobs_total: 0,
cpu: 8,
memory: 6232231936,
cpu_capacity: 32,
mem_capacity: 38,
enabled: true,
managed_by_policy: true,
node_type: b,
node_state: 'ready',
health_check_pending: false,
},
});
jest.spyOn(ConfigContext, 'useConfig').mockImplementation(() => ({
me: { is_superuser: true },
}));
await act(async () => {
wrapper = mountWithContexts(
<InstanceDetails
instanceGroup={instanceGroup}
setBreadcrumb={() => {}}
/>
);
});
await waitForElement(wrapper, 'ContentLoading', (el) => el.length === 0);
expect(wrapper.find("Button[ouiaId='health-check-button']")).toHaveLength(
expected
);
}
);
test('Should call disassociate', async () => {
InstanceGroupsAPI.readInstances.mockResolvedValue({
data: {

View File

@ -35,6 +35,8 @@ const QS_CONFIG = getQSConfig('instance', {
function InstanceList({ instanceGroup }) {
const [isModalOpen, setIsModalOpen] = useState(false);
const [showHealthCheckAlert, setShowHealthCheckAlert] = useState(false);
const [pendingHealthCheck, setPendingHealthCheck] = useState(false);
const [canRunHealthCheck, setCanRunHealthCheck] = useState(true);
const location = useLocation();
const { id: instanceGroupId } = useParams();
@ -56,6 +58,12 @@ function InstanceList({ instanceGroup }) {
InstanceGroupsAPI.readInstances(instanceGroupId, params),
InstanceGroupsAPI.readInstanceOptions(instanceGroupId),
]);
response.data.results.forEach((i) => {
if (i.health_check_pending === true) {
setPendingHealthCheck(true);
}
});
return {
instances: response.data.results,
count: response.data.count,
@ -90,7 +98,7 @@ function InstanceList({ instanceGroup }) {
useCallback(async () => {
const [...response] = await Promise.all(
selected
.filter(({ node_type }) => node_type !== 'hop')
.filter(({ node_type }) => node_type === 'execution')
.map(({ id }) => InstancesAPI.healthCheck(id))
);
if (response) {
@ -99,6 +107,18 @@ function InstanceList({ instanceGroup }) {
}, [selected])
);
useEffect(() => {
if (selected) {
selected.forEach((i) => {
if (i.node_type === 'execution') {
setCanRunHealthCheck(true);
} else {
setCanRunHealthCheck(false);
}
});
}
}, [selected]);
const handleHealthCheck = async () => {
await fetchHealthCheck();
clearSelected();
@ -246,9 +266,10 @@ function InstanceList({ instanceGroup }) {
isProtectedInstanceGroup={instanceGroup.name === 'controlplane'}
/>,
<HealthCheckButton
isDisabled={!canAdd}
isDisabled={!canAdd || !canRunHealthCheck}
onClick={handleHealthCheck}
selectedItems={selected}
healthCheckPending={pendingHealthCheck}
/>,
]}
emptyStateControls={
@ -263,7 +284,10 @@ function InstanceList({ instanceGroup }) {
)}
headerRow={
<HeaderRow qsConfig={QS_CONFIG} isExpandable>
<HeaderCell sortKey="hostname">{t`Name`}</HeaderCell>
<HeaderCell
tooltip={t`Health checks can only be run on execution nodes.`}
sortKey="hostname"
>{t`Name`}</HeaderCell>
<HeaderCell sortKey="errors">{t`Status`}</HeaderCell>
<HeaderCell sortKey="node_type">{t`Node Type`}</HeaderCell>
<HeaderCell>{t`Capacity Adjustment`}</HeaderCell>

View File

@ -172,7 +172,7 @@ describe('<InstanceList/>', () => {
await act(async () =>
wrapper.find('Button[ouiaId="health-check"]').prop('onClick')()
);
expect(InstancesAPI.healthCheck).toBeCalledTimes(3);
expect(InstancesAPI.healthCheck).toBeCalledTimes(1);
});
test('should render health check error', async () => {
InstancesAPI.healthCheck.mockRejectedValue(

View File

@ -11,6 +11,7 @@ import {
Slider,
Tooltip,
} from '@patternfly/react-core';
import { OutlinedClockIcon } from '@patternfly/react-icons';
import { Tr, Td, ExpandableRowContent } from '@patternfly/react-table';
import { formatDateString } from 'util/dates';
import { ActionsTd, ActionItem } from 'components/PaginatedTable';
@ -100,6 +101,18 @@ function InstanceListItem({
debounceUpdateInstance({ capacity_adjustment: roundedValue });
};
const formatHealthCheckTimeStamp = (last) => (
<>
{formatDateString(last)}
{instance.health_check_pending ? (
<>
{' '}
<OutlinedClockIcon />
</>
) : null}
</>
);
return (
<>
<Tr
@ -206,7 +219,8 @@ function InstanceListItem({
<Detail
data-cy="last-health-check"
label={t`Last Health Check`}
value={formatDateString(instance.last_health_check)}
helpText={t`Health checks are asynchronous tasks. See the docs for more details.`}
value={formatHealthCheckTimeStamp(instance.last_health_check)}
/>
</DetailList>
</ExpandableRowContent>

View File

@ -281,8 +281,8 @@ describe('<InstanceListItem/>', () => {
expect(wrapper.find('Detail[label="Policy Type"]').prop('value')).toBe(
'Auto'
);
expect(
wrapper.find('Detail[label="Last Health Check"]').prop('value')
).toBe('9/15/2021, 6:02:07 PM');
expect(wrapper.find('Detail[label="Last Health Check"]').text()).toBe(
'Last Health Check9/15/2021, 6:02:07 PM'
);
});
});

View File

@ -13,7 +13,7 @@ import {
Slider,
Label,
} from '@patternfly/react-core';
import { DownloadIcon } from '@patternfly/react-icons';
import { DownloadIcon, OutlinedClockIcon } from '@patternfly/react-icons';
import styled from 'styled-components';
import { useConfig } from 'contexts/Config';
@ -85,8 +85,8 @@ function InstanceDetail({ setBreadcrumb, isK8s }) {
InstancesAPI.readDetail(id),
InstancesAPI.readInstanceGroup(id),
]);
if (details.node_type !== 'hop') {
// we probably don't need this extra call
if (details.node_type === 'execution') {
const { data: healthCheckData } =
await InstancesAPI.readHealthCheckDetail(id);
setHealthCheck(healthCheckData);
@ -115,15 +115,9 @@ function InstanceDetail({ setBreadcrumb, isK8s }) {
setBreadcrumb(instance);
}
}, [instance, setBreadcrumb]);
const {
error: healthCheckError,
isLoading: isRunningHealthCheck,
request: fetchHealthCheck,
} = useRequest(
const { error: healthCheckError, request: fetchHealthCheck } = useRequest(
useCallback(async () => {
const { status } = await InstancesAPI.healthCheck(id);
const { data } = await InstancesAPI.readHealthCheckDetail(id);
setHealthCheck(data);
if (status === 200) {
setShowHealthCheckAlert(true);
}
@ -149,6 +143,18 @@ function InstanceDetail({ setBreadcrumb, isK8s }) {
debounceUpdateInstance({ capacity_adjustment: roundedValue });
};
const formatHealthCheckTimeStamp = (last) => (
<>
{formatDateString(last)}
{instance.health_check_pending ? (
<>
{' '}
<OutlinedClockIcon />
</>
) : null}
</>
);
const buildLinkURL = (inst) =>
inst.is_container_group
? '/instance_groups/container_group/'
@ -179,6 +185,7 @@ function InstanceDetail({ setBreadcrumb, isK8s }) {
return <ContentLoading />;
}
const isHopNode = instance.node_type === 'hop';
const isExecutionNode = instance.node_type === 'execution';
return (
<>
@ -242,7 +249,8 @@ function InstanceDetail({ setBreadcrumb, isK8s }) {
<Detail
label={t`Last Health Check`}
dataCy="last-health-check"
value={formatDateString(healthCheck?.last_health_check)}
helpText={t`Health checks are asynchronous tasks. See the docs for more details.`}
value={formatHealthCheckTimeStamp(instance.last_health_check)}
/>
{instance.related?.install_bundle && (
<Detail
@ -332,18 +340,22 @@ function InstanceDetail({ setBreadcrumb, isK8s }) {
onRemove={removeInstances}
/>
)}
<Tooltip content={t`Run a health check on the instance`}>
<Button
isDisabled={!me.is_superuser || isRunningHealthCheck}
variant="primary"
ouiaId="health-check-button"
onClick={fetchHealthCheck}
isLoading={isRunningHealthCheck}
spinnerAriaLabel={t`Running health check`}
>
{t`Run health check`}
</Button>
</Tooltip>
{isExecutionNode && (
<Tooltip content={t`Run a health check on the instance`}>
<Button
isDisabled={!me.is_superuser || instance.health_check_pending}
variant="primary"
ouiaId="health-check-button"
onClick={fetchHealthCheck}
isLoading={instance.health_check_pending}
spinnerAriaLabel={t`Running health check`}
>
{instance.health_check_pending
? t`Running health check`
: t`Run health check`}
</Button>
</Tooltip>
)}
<InstanceToggle
css="display: inline-flex;"
fetchInstances={fetchDetails}

View File

@ -49,8 +49,9 @@ describe('<InstanceDetail/>', () => {
mem_capacity: 38,
enabled: true,
managed_by_policy: true,
node_type: 'hybrid',
node_type: 'execution',
node_state: 'ready',
health_check_pending: false,
},
});
InstancesAPI.readInstanceGroup.mockResolvedValue({

View File

@ -37,6 +37,8 @@ function InstanceList() {
const location = useLocation();
const { me } = useConfig();
const [showHealthCheckAlert, setShowHealthCheckAlert] = useState(false);
const [pendingHealthCheck, setPendingHealthCheck] = useState(false);
const [canRunHealthCheck, setCanRunHealthCheck] = useState(true);
const {
result: { instances, count, relatedSearchableKeys, searchableKeys, isK8s },
@ -51,6 +53,11 @@ function InstanceList() {
InstancesAPI.readOptions(),
SettingsAPI.readCategory('system'),
]);
response.data.results.forEach((i) => {
if (i.health_check_pending === true) {
setPendingHealthCheck(true);
}
});
return {
instances: response.data.results,
isK8s: sysSettings.data.IS_K8S,
@ -87,7 +94,7 @@ function InstanceList() {
useCallback(async () => {
const [...response] = await Promise.all(
selected
.filter(({ node_type }) => node_type !== 'hop')
.filter(({ node_type }) => node_type === 'execution')
.map(({ id }) => InstancesAPI.healthCheck(id))
);
if (response) {
@ -96,6 +103,18 @@ function InstanceList() {
}, [selected])
);
useEffect(() => {
if (selected) {
selected.forEach((i) => {
if (i.node_type === 'execution') {
setCanRunHealthCheck(true);
} else {
setCanRunHealthCheck(false);
}
});
}
}, [selected]);
const handleHealthCheck = async () => {
await fetchHealthCheck();
clearSelected();
@ -189,6 +208,8 @@ function InstanceList() {
onClick={handleHealthCheck}
key="healthCheck"
selectedItems={selected}
healthCheckPending={pendingHealthCheck}
isDisabled={!canRunHealthCheck}
/>,
]}
/>
@ -196,7 +217,7 @@ function InstanceList() {
headerRow={
<HeaderRow qsConfig={QS_CONFIG} isExpandable>
<HeaderCell
tooltip={t`Cannot run health check on hop nodes.`}
tooltip={t`Health checks can only be run on execution nodes.`}
sortKey="hostname"
>{t`Name`}</HeaderCell>
<HeaderCell sortKey="errors">{t`Status`}</HeaderCell>

View File

@ -32,7 +32,7 @@ const instances = [
jobs_running: 0,
jobs_total: 68,
cpu: 6,
node_type: 'control',
node_type: 'execution',
node_state: 'ready',
memory: 2087469056,
cpu_capacity: 24,
@ -52,7 +52,7 @@ const instances = [
jobs_running: 0,
jobs_total: 68,
cpu: 6,
node_type: 'hybrid',
node_type: 'execution',
node_state: 'ready',
memory: 2087469056,
cpu_capacity: 24,

View File

@ -11,6 +11,7 @@ import {
Slider,
Tooltip,
} from '@patternfly/react-core';
import { OutlinedClockIcon } from '@patternfly/react-icons';
import { Tr, Td, ExpandableRowContent } from '@patternfly/react-table';
import { formatDateString } from 'util/dates';
import computeForks from 'util/computeForks';
@ -98,7 +99,21 @@ function InstanceListItem({
);
debounceUpdateInstance({ capacity_adjustment: roundedValue });
};
const formatHealthCheckTimeStamp = (last) => (
<>
{formatDateString(last)}
{instance.health_check_pending ? (
<>
{' '}
<OutlinedClockIcon />
</>
) : null}
</>
);
const isHopNode = instance.node_type === 'hop';
const isExecutionNode = instance.node_type === 'execution';
return (
<>
<Tr
@ -121,7 +136,7 @@ function InstanceListItem({
rowIndex,
isSelected,
onSelect,
disable: isHopNode,
disable: !isExecutionNode,
}}
dataLabel={t`Selected`}
/>
@ -221,7 +236,8 @@ function InstanceListItem({
<Detail
data-cy="last-health-check"
label={t`Last Health Check`}
value={formatDateString(instance.last_health_check)}
helpText={t`Health checks are asynchronous tasks. See the docs for more details.`}
value={formatHealthCheckTimeStamp(instance.last_health_check)}
/>
</DetailList>
</ExpandableRowContent>

View File

@ -272,9 +272,9 @@ describe('<InstanceListItem/>', () => {
expect(wrapper.find('Detail[label="Policy Type"]').prop('value')).toBe(
'Auto'
);
expect(
wrapper.find('Detail[label="Last Health Check"]').prop('value')
).toBe('9/15/2021, 6:02:07 PM');
expect(wrapper.find('Detail[label="Last Health Check"]').text()).toBe(
'Last Health Check9/15/2021, 6:02:07 PM'
);
});
test('Hop should not render some things', async () => {
const onSelect = jest.fn();