mirror of
https://github.com/ansible/awx.git
synced 2026-01-21 22:48:02 -03:30
Merge pull request #11653 from AlexSCorey/11588-TopLevelInstances
Adds top level instances list
This commit is contained in:
commit
dabae456d9
51
awx/ui/src/components/HealthCheckButton/HealthCheckButton.js
Normal file
51
awx/ui/src/components/HealthCheckButton/HealthCheckButton.js
Normal file
@ -0,0 +1,51 @@
|
||||
import React from 'react';
|
||||
import { Plural, t } from '@lingui/macro';
|
||||
import { Button, DropdownItem, Tooltip } from '@patternfly/react-core';
|
||||
import { useKebabifiedMenu } from 'contexts/Kebabified';
|
||||
|
||||
function HealthCheckButton({ isDisabled, onClick, selectedItems }) {
|
||||
const { isKebabified } = useKebabifiedMenu();
|
||||
|
||||
const selectedItemsCount = selectedItems.length;
|
||||
|
||||
const buildTooltip = () =>
|
||||
selectedItemsCount ? (
|
||||
<Plural
|
||||
value={selectedItemsCount}
|
||||
one="Click to run a health check on the selected instance."
|
||||
other="Click to run a health check on the selected instances."
|
||||
/>
|
||||
) : (
|
||||
t`Select an instance to run a health check.`
|
||||
);
|
||||
|
||||
if (isKebabified) {
|
||||
return (
|
||||
<Tooltip data-cy="healthCheckTooltip" content={buildTooltip()}>
|
||||
<DropdownItem
|
||||
key="approve"
|
||||
isDisabled={isDisabled || !selectedItemsCount}
|
||||
component="button"
|
||||
onClick={onClick}
|
||||
ouiaId="health-check"
|
||||
>
|
||||
{t`Health Check`}
|
||||
</DropdownItem>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Tooltip data-cy="healthCheckTooltip" content={buildTooltip()}>
|
||||
<div>
|
||||
<Button
|
||||
isDisabled={isDisabled || !selectedItemsCount}
|
||||
variant="secondary"
|
||||
ouiaId="health-check"
|
||||
onClick={onClick}
|
||||
>{t`Health Check`}</Button>
|
||||
</div>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
export default HealthCheckButton;
|
||||
1
awx/ui/src/components/HealthCheckButton/index.js
Normal file
1
awx/ui/src/components/HealthCheckButton/index.js
Normal file
@ -0,0 +1 @@
|
||||
export { default } from './HealthCheckButton';
|
||||
@ -69,6 +69,7 @@ export function HeaderCell({
|
||||
idPrefix,
|
||||
className,
|
||||
children,
|
||||
tooltip,
|
||||
}) {
|
||||
const sort = sortKey
|
||||
? {
|
||||
@ -79,6 +80,11 @@ export function HeaderCell({
|
||||
: null;
|
||||
return (
|
||||
<Th
|
||||
info={
|
||||
tooltip && {
|
||||
popover: <div>{tooltip}</div>,
|
||||
}
|
||||
}
|
||||
id={sortKey ? `${idPrefix}-${sortKey}` : null}
|
||||
className={className}
|
||||
sort={sort}
|
||||
|
||||
@ -56,6 +56,7 @@ function ToolbarAddButton({
|
||||
return (
|
||||
<Button
|
||||
ouiaId={ouiaId}
|
||||
isDisabled={isDisabled}
|
||||
icon={showToggleIndicator ? <CaretDownIcon /> : null}
|
||||
iconPosition={showToggleIndicator ? 'right' : null}
|
||||
variant="primary"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -8,6 +8,7 @@ import Credentials from 'screens/Credential';
|
||||
import Dashboard from 'screens/Dashboard';
|
||||
import ExecutionEnvironments from 'screens/ExecutionEnvironment';
|
||||
import Hosts from 'screens/Host';
|
||||
import Instances from 'screens/Instances';
|
||||
import InstanceGroups from 'screens/InstanceGroup';
|
||||
import Inventory from 'screens/Inventory';
|
||||
import ManagementJobs from 'screens/ManagementJob';
|
||||
@ -131,6 +132,11 @@ function getRouteConfig(userProfile = {}) {
|
||||
path: '/instance_groups',
|
||||
screen: InstanceGroups,
|
||||
},
|
||||
{
|
||||
title: <Trans>Instances</Trans>,
|
||||
path: '/instances',
|
||||
screen: Instances,
|
||||
},
|
||||
{
|
||||
title: <Trans>Applications</Trans>,
|
||||
path: '/applications',
|
||||
|
||||
@ -40,6 +40,7 @@ describe('getRouteConfig', () => {
|
||||
'/notification_templates',
|
||||
'/management_jobs',
|
||||
'/instance_groups',
|
||||
'/instances',
|
||||
'/applications',
|
||||
'/execution_environments',
|
||||
'/settings',
|
||||
@ -67,6 +68,7 @@ describe('getRouteConfig', () => {
|
||||
'/notification_templates',
|
||||
'/management_jobs',
|
||||
'/instance_groups',
|
||||
'/instances',
|
||||
'/applications',
|
||||
'/execution_environments',
|
||||
'/settings',
|
||||
@ -93,6 +95,7 @@ describe('getRouteConfig', () => {
|
||||
'/credential_types',
|
||||
'/notification_templates',
|
||||
'/instance_groups',
|
||||
'/instances',
|
||||
'/applications',
|
||||
'/execution_environments',
|
||||
]);
|
||||
@ -120,6 +123,7 @@ describe('getRouteConfig', () => {
|
||||
'/teams',
|
||||
'/credential_types',
|
||||
'/notification_templates',
|
||||
'/instances',
|
||||
'/applications',
|
||||
'/execution_environments',
|
||||
]);
|
||||
@ -143,6 +147,7 @@ describe('getRouteConfig', () => {
|
||||
'/users',
|
||||
'/teams',
|
||||
'/credential_types',
|
||||
'/instances',
|
||||
'/applications',
|
||||
'/execution_environments',
|
||||
]);
|
||||
@ -166,6 +171,7 @@ describe('getRouteConfig', () => {
|
||||
'/users',
|
||||
'/teams',
|
||||
'/credential_types',
|
||||
'/instances',
|
||||
'/applications',
|
||||
'/execution_environments',
|
||||
]);
|
||||
@ -194,6 +200,7 @@ describe('getRouteConfig', () => {
|
||||
'/teams',
|
||||
'/credential_types',
|
||||
'/notification_templates',
|
||||
'/instances',
|
||||
'/applications',
|
||||
'/execution_environments',
|
||||
]);
|
||||
@ -223,6 +230,7 @@ describe('getRouteConfig', () => {
|
||||
'/credential_types',
|
||||
'/notification_templates',
|
||||
'/instance_groups',
|
||||
'/instances',
|
||||
'/applications',
|
||||
'/execution_environments',
|
||||
]);
|
||||
@ -252,6 +260,7 @@ describe('getRouteConfig', () => {
|
||||
'/credential_types',
|
||||
'/notification_templates',
|
||||
'/instance_groups',
|
||||
'/instances',
|
||||
'/applications',
|
||||
'/execution_environments',
|
||||
]);
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import { Plural, t } from '@lingui/macro';
|
||||
import { t } from '@lingui/macro';
|
||||
import { useLocation, useParams } from 'react-router-dom';
|
||||
import 'styled-components/macro';
|
||||
|
||||
@ -16,7 +15,6 @@ import DisassociateButton from 'components/DisassociateButton';
|
||||
import AssociateModal from 'components/AssociateModal';
|
||||
import AlertModal from 'components/AlertModal';
|
||||
import ErrorDetail from 'components/ErrorDetail';
|
||||
|
||||
import useRequest, {
|
||||
useDeleteItems,
|
||||
useDismissableError,
|
||||
@ -24,8 +22,7 @@ import useRequest, {
|
||||
import useSelected from 'hooks/useSelected';
|
||||
import { InstanceGroupsAPI, InstancesAPI } from 'api';
|
||||
import { getQSConfig, parseQueryString, mergeParams } from 'util/qs';
|
||||
|
||||
import { Button, Tooltip } from '@patternfly/react-core';
|
||||
import HealthCheckButton from 'components/HealthCheckButton/HealthCheckButton';
|
||||
import InstanceListItem from './InstanceListItem';
|
||||
|
||||
const QS_CONFIG = getQSConfig('instance', {
|
||||
@ -83,13 +80,22 @@ function InstanceList() {
|
||||
fetchInstances();
|
||||
}, [fetchInstances]);
|
||||
|
||||
const { error: healthCheckError, request: fetchHealthCheck } = useRequest(
|
||||
const {
|
||||
error: healthCheckError,
|
||||
request: fetchHealthCheck,
|
||||
isLoading: isHealthCheckLoading,
|
||||
} = useRequest(
|
||||
useCallback(async () => {
|
||||
await Promise.all(selected.map(({ id }) => InstancesAPI.healthCheck(id)));
|
||||
fetchInstances();
|
||||
}, [selected, fetchInstances])
|
||||
);
|
||||
|
||||
const handleHealthCheck = async () => {
|
||||
await fetchHealthCheck();
|
||||
clearSelected();
|
||||
};
|
||||
|
||||
const {
|
||||
isLoading: isDisassociateLoading,
|
||||
deleteItems: disassociateInstances,
|
||||
@ -150,6 +156,7 @@ function InstanceList() {
|
||||
mergeParams(params, {
|
||||
...{ not__rampart_groups__id: instanceGroupId },
|
||||
...{ not__node_type: 'control' },
|
||||
...{ not__node_type: 'hop' },
|
||||
})
|
||||
),
|
||||
[instanceGroupId]
|
||||
@ -167,7 +174,9 @@ function InstanceList() {
|
||||
<>
|
||||
<PaginatedTable
|
||||
contentError={contentError}
|
||||
hasContentLoading={isLoading || isDisassociateLoading}
|
||||
hasContentLoading={
|
||||
isLoading || isDisassociateLoading || isHealthCheckLoading
|
||||
}
|
||||
items={instances}
|
||||
itemCount={count}
|
||||
pluralizedItemName={t`Instances`}
|
||||
@ -181,6 +190,15 @@ function InstanceList() {
|
||||
key: 'hostname__icontains',
|
||||
isDefault: true,
|
||||
},
|
||||
{
|
||||
name: t`Node Type`,
|
||||
key: `or__node_type`,
|
||||
options: [
|
||||
[`control`, t`Control`],
|
||||
[`execution`, t`Execution`],
|
||||
[`hybrid`, t`Hybrid`],
|
||||
],
|
||||
},
|
||||
]}
|
||||
toolbarSortColumns={[
|
||||
{
|
||||
@ -215,28 +233,11 @@ function InstanceList() {
|
||||
itemsToDisassociate={selected}
|
||||
modalTitle={t`Disassociate instance from instance group?`}
|
||||
/>,
|
||||
<Tooltip
|
||||
content={
|
||||
selected.length ? (
|
||||
<Plural
|
||||
value={selected.length}
|
||||
one="Click to run a health check on the selected instance."
|
||||
other="Click to run a health check on the selected instances."
|
||||
/>
|
||||
) : (
|
||||
t`Select an instance to run a health check.`
|
||||
)
|
||||
}
|
||||
>
|
||||
<div>
|
||||
<Button
|
||||
isDisabled={!canAdd || !selected.length}
|
||||
variant="secondary"
|
||||
ouiaId="health-check"
|
||||
onClick={fetchHealthCheck}
|
||||
>{t`Health Check`}</Button>
|
||||
</div>
|
||||
</Tooltip>,
|
||||
<HealthCheckButton
|
||||
isDisabled={!canAdd}
|
||||
onClick={handleHealthCheck}
|
||||
selectedItems={selected}
|
||||
/>,
|
||||
]}
|
||||
emptyStateControls={
|
||||
canAdd ? (
|
||||
@ -252,8 +253,7 @@ function InstanceList() {
|
||||
<HeaderRow qsConfig={QS_CONFIG} isExpandable>
|
||||
<HeaderCell sortKey="hostname">{t`Name`}</HeaderCell>
|
||||
<HeaderCell sortKey="errors">{t`Status`}</HeaderCell>
|
||||
<HeaderCell>{t`Running Jobs`}</HeaderCell>
|
||||
<HeaderCell>{t`Total Jobs`}</HeaderCell>
|
||||
<HeaderCell sortKey="node_type">{t`Node Type`}</HeaderCell>
|
||||
<HeaderCell>{t`Capacity Adjustment`}</HeaderCell>
|
||||
<HeaderCell>{t`Used Capacity`}</HeaderCell>
|
||||
<HeaderCell>{t`Actions`}</HeaderCell>
|
||||
|
||||
@ -19,6 +19,7 @@ import StatusLabel from 'components/StatusLabel';
|
||||
import { Instance } from 'types';
|
||||
import useRequest, { useDismissableError } from 'hooks/useRequest';
|
||||
import useDebounce from 'hooks/useDebounce';
|
||||
import computeForks from 'util/computeForks';
|
||||
import { InstancesAPI } from 'api';
|
||||
import { useConfig } from 'contexts/Config';
|
||||
import AlertModal from 'components/AlertModal';
|
||||
@ -42,15 +43,6 @@ const SliderForks = styled.div`
|
||||
text-align: center;
|
||||
`;
|
||||
|
||||
function computeForks(memCapacity, cpuCapacity, selectedCapacityAdjustment) {
|
||||
const minCapacity = Math.min(memCapacity, cpuCapacity);
|
||||
const maxCapacity = Math.max(memCapacity, cpuCapacity);
|
||||
|
||||
return Math.floor(
|
||||
minCapacity + (maxCapacity - minCapacity) * selectedCapacityAdjustment
|
||||
);
|
||||
}
|
||||
|
||||
function InstanceListItem({
|
||||
instance,
|
||||
isExpanded,
|
||||
@ -61,6 +53,7 @@ function InstanceListItem({
|
||||
rowIndex,
|
||||
}) {
|
||||
const { me = {} } = useConfig();
|
||||
const { id } = useParams();
|
||||
const [forks, setForks] = useState(
|
||||
computeForks(
|
||||
instance.mem_capacity,
|
||||
@ -68,7 +61,6 @@ function InstanceListItem({
|
||||
instance.capacity_adjustment
|
||||
)
|
||||
);
|
||||
const { id } = useParams();
|
||||
|
||||
const labelId = `check-action-${instance.id}`;
|
||||
|
||||
@ -147,8 +139,7 @@ function InstanceListItem({
|
||||
<StatusLabel status={instance.errors ? 'error' : 'healthy'} />
|
||||
</Tooltip>
|
||||
</Td>
|
||||
<Td dataLabel={t`Running Jobs`}>{instance.jobs_running}</Td>
|
||||
<Td dataLabel={t`Total Jobs`}>{instance.jobs_total}</Td>
|
||||
<Td dataLabel={t`Node Type`}>{instance.node_type}</Td>
|
||||
<Td dataLabel={t`Capacity Adjustment`}>
|
||||
<SliderHolder data-cy="slider-holder">
|
||||
<div data-cy="cpu-capacity">{t`CPU ${instance.cpu_capacity}`}</div>
|
||||
@ -197,12 +188,23 @@ function InstanceListItem({
|
||||
<Td colSpan={7}>
|
||||
<ExpandableRowContent>
|
||||
<DetailList>
|
||||
<Detail label={t`Node Type`} value={instance.node_type} />
|
||||
<Detail
|
||||
data-cy="running-jobs"
|
||||
value={instance.jobs_running}
|
||||
label={t`Running Jobs`}
|
||||
/>
|
||||
<Detail
|
||||
data-cy="total-jobs"
|
||||
value={instance.jobs_total}
|
||||
label={t`Total Jobs`}
|
||||
/>
|
||||
<Detail
|
||||
data-cy="policy-type"
|
||||
label={t`Policy Type`}
|
||||
value={instance.managed_by_policy ? t`Auto` : t`Manual`}
|
||||
/>
|
||||
<Detail
|
||||
data-cy="last-health-check"
|
||||
label={t`Last Health Check`}
|
||||
value={formatDateString(instance.last_health_check)}
|
||||
/>
|
||||
|
||||
@ -274,9 +274,8 @@ describe('<InstanceListItem/>', () => {
|
||||
);
|
||||
});
|
||||
expect(wrapper.find('InstanceListItem').prop('isExpanded')).toBe(true);
|
||||
expect(wrapper.find('Detail[label="Node Type"]').prop('value')).toBe(
|
||||
'hybrid'
|
||||
);
|
||||
expect(wrapper.find('Detail[label="Running Jobs"]').prop('value')).toBe(0);
|
||||
expect(wrapper.find('Detail[label="Total Jobs"]').prop('value')).toBe(68);
|
||||
expect(wrapper.find('Detail[label="Policy Type"]').prop('value')).toBe(
|
||||
'Auto'
|
||||
);
|
||||
|
||||
51
awx/ui/src/screens/Instances/Instance.js
Normal file
51
awx/ui/src/screens/Instances/Instance.js
Normal file
@ -0,0 +1,51 @@
|
||||
import React from 'react';
|
||||
import { t } from '@lingui/macro';
|
||||
|
||||
import { Switch, Route, Redirect, Link, useRouteMatch } from 'react-router-dom';
|
||||
import { CaretLeftIcon } from '@patternfly/react-icons';
|
||||
import { Card, PageSection } from '@patternfly/react-core';
|
||||
import ContentError from 'components/ContentError';
|
||||
import RoutedTabs from 'components/RoutedTabs';
|
||||
import InstanceDetail from './InstanceDetail';
|
||||
|
||||
function Instance({ setBreadcrumb }) {
|
||||
const match = useRouteMatch();
|
||||
const tabsArray = [
|
||||
{
|
||||
name: (
|
||||
<>
|
||||
<CaretLeftIcon />
|
||||
{t`Back to Instances`}
|
||||
</>
|
||||
),
|
||||
link: `/instances`,
|
||||
id: 99,
|
||||
},
|
||||
{ name: t`Details`, link: `${match.url}/details`, id: 0 },
|
||||
];
|
||||
|
||||
return (
|
||||
<PageSection>
|
||||
<Card>
|
||||
<RoutedTabs tabsArray={tabsArray} />
|
||||
<Switch>
|
||||
<Redirect from="/instances/:id" to="/instances/:id/details" exact />
|
||||
<Route path="/instances/:id/details" key="details">
|
||||
<InstanceDetail setBreadcrumb={setBreadcrumb} />
|
||||
</Route>
|
||||
<Route path="*" key="not-found">
|
||||
<ContentError isNotFound>
|
||||
{match.params.id && (
|
||||
<Link to={`/instances/${match.params.id}/details`}>
|
||||
{t`View Instance Details`}
|
||||
</Link>
|
||||
)}
|
||||
</ContentError>
|
||||
</Route>
|
||||
</Switch>
|
||||
</Card>
|
||||
</PageSection>
|
||||
);
|
||||
}
|
||||
|
||||
export default Instance;
|
||||
256
awx/ui/src/screens/Instances/InstanceDetail/InstanceDetail.js
Normal file
256
awx/ui/src/screens/Instances/InstanceDetail/InstanceDetail.js
Normal file
@ -0,0 +1,256 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { t, Plural } from '@lingui/macro';
|
||||
import {
|
||||
Button,
|
||||
Progress,
|
||||
ProgressMeasureLocation,
|
||||
ProgressSize,
|
||||
CodeBlock,
|
||||
CodeBlockCode,
|
||||
Tooltip,
|
||||
Slider,
|
||||
} from '@patternfly/react-core';
|
||||
import styled from 'styled-components';
|
||||
|
||||
import { useConfig } from 'contexts/Config';
|
||||
import { InstancesAPI } from 'api';
|
||||
import useDebounce from 'hooks/useDebounce';
|
||||
import AlertModal from 'components/AlertModal';
|
||||
import ErrorDetail from 'components/ErrorDetail';
|
||||
import InstanceToggle from 'components/InstanceToggle';
|
||||
import { CardBody, CardActionsRow } from 'components/Card';
|
||||
import { formatDateString } from 'util/dates';
|
||||
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';
|
||||
|
||||
const Unavailable = styled.span`
|
||||
color: var(--pf-global--danger-color--200);
|
||||
`;
|
||||
|
||||
const SliderHolder = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
`;
|
||||
|
||||
const SliderForks = styled.div`
|
||||
flex-grow: 1;
|
||||
margin-right: 8px;
|
||||
margin-left: 8px;
|
||||
text-align: center;
|
||||
`;
|
||||
|
||||
function computeForks(memCapacity, cpuCapacity, selectedCapacityAdjustment) {
|
||||
const minCapacity = Math.min(memCapacity, cpuCapacity);
|
||||
const maxCapacity = Math.max(memCapacity, cpuCapacity);
|
||||
|
||||
return Math.floor(
|
||||
minCapacity + (maxCapacity - minCapacity) * selectedCapacityAdjustment
|
||||
);
|
||||
}
|
||||
|
||||
function InstanceDetail({ setBreadcrumb }) {
|
||||
const { me = {} } = useConfig();
|
||||
const { id } = useParams();
|
||||
const [forks, setForks] = useState();
|
||||
|
||||
const [healthCheck, setHealthCheck] = useState({});
|
||||
|
||||
const {
|
||||
isLoading,
|
||||
error: contentError,
|
||||
request: fetchDetails,
|
||||
result: instance,
|
||||
} = useRequest(
|
||||
useCallback(async () => {
|
||||
const { data: details } = await InstancesAPI.readDetail(id);
|
||||
|
||||
if (details.node_type !== 'hop') {
|
||||
const { data: healthCheckData } =
|
||||
await InstancesAPI.readHealthCheckDetail(id);
|
||||
setHealthCheck(healthCheckData);
|
||||
}
|
||||
|
||||
setForks(
|
||||
computeForks(
|
||||
details.mem_capacity,
|
||||
details.cpu_capacity,
|
||||
details.capacity_adjustment
|
||||
)
|
||||
);
|
||||
return details;
|
||||
}, [id]),
|
||||
{}
|
||||
);
|
||||
useEffect(() => {
|
||||
fetchDetails();
|
||||
}, [fetchDetails]);
|
||||
|
||||
useEffect(() => {
|
||||
if (instance) {
|
||||
setBreadcrumb(instance);
|
||||
}
|
||||
}, [instance, setBreadcrumb]);
|
||||
|
||||
const { error: healthCheckError, request: fetchHealthCheck } = useRequest(
|
||||
useCallback(async () => {
|
||||
const { data } = await InstancesAPI.healthCheck(id);
|
||||
setHealthCheck(data);
|
||||
}, [id])
|
||||
);
|
||||
|
||||
const { error: updateInstanceError, request: updateInstance } = useRequest(
|
||||
useCallback(
|
||||
async (values) => {
|
||||
await InstancesAPI.update(id, values);
|
||||
},
|
||||
[id]
|
||||
)
|
||||
);
|
||||
|
||||
const debounceUpdateInstance = useDebounce(updateInstance, 200);
|
||||
|
||||
const handleChangeValue = (value) => {
|
||||
const roundedValue = Math.round(value * 100) / 100;
|
||||
setForks(
|
||||
computeForks(instance.mem_capacity, instance.cpu_capacity, roundedValue)
|
||||
);
|
||||
debounceUpdateInstance({ capacity_adjustment: roundedValue });
|
||||
};
|
||||
|
||||
const { error, dismissError } = useDismissableError(
|
||||
updateInstanceError || healthCheckError
|
||||
);
|
||||
if (contentError) {
|
||||
return <ContentError error={contentError} />;
|
||||
}
|
||||
if (isLoading) {
|
||||
return <ContentLoading />;
|
||||
}
|
||||
const isHopNode = instance.node_type === 'hop';
|
||||
return (
|
||||
<CardBody>
|
||||
<DetailList gutter="sm">
|
||||
<Detail
|
||||
label={t`Host Name`}
|
||||
value={instance.hostname}
|
||||
dataCy="instance-detail-name"
|
||||
/>
|
||||
<Detail
|
||||
label={t`Status`}
|
||||
value={
|
||||
<StatusLabel status={healthCheck?.errors ? 'error' : 'healthy'} />
|
||||
}
|
||||
/>
|
||||
<Detail label={t`Node Type`} value={instance.node_type} />
|
||||
{!isHopNode && (
|
||||
<>
|
||||
<Detail
|
||||
label={t`Policy Type`}
|
||||
value={instance.managed_by_policy ? t`Auto` : t`Manual`}
|
||||
/>
|
||||
<Detail label={t`Running Jobs`} value={instance.jobs_running} />
|
||||
<Detail label={t`Total Jobs`} value={instance.jobs_total} />
|
||||
<Detail
|
||||
label={t`Last Health Check`}
|
||||
value={formatDateString(healthCheck?.last_health_check)}
|
||||
/>
|
||||
<Detail
|
||||
label={t`Capacity Adjustment`}
|
||||
value={
|
||||
<SliderHolder data-cy="slider-holder">
|
||||
<div data-cy="cpu-capacity">{t`CPU ${instance.cpu_capacity}`}</div>
|
||||
<SliderForks data-cy="slider-forks">
|
||||
<div data-cy="number-forks">
|
||||
<Plural value={forks} one="# fork" other="# forks" />
|
||||
</div>
|
||||
<Slider
|
||||
areCustomStepsContinuous
|
||||
max={1}
|
||||
min={0}
|
||||
step={0.1}
|
||||
value={instance.capacity_adjustment}
|
||||
onChange={handleChangeValue}
|
||||
isDisabled={!me?.is_superuser || !instance.enabled}
|
||||
data-cy="slider"
|
||||
/>
|
||||
</SliderForks>
|
||||
<div data-cy="mem-capacity">{t`RAM ${instance.mem_capacity}`}</div>
|
||||
</SliderHolder>
|
||||
}
|
||||
/>
|
||||
<Detail
|
||||
label={t`Used Capacity`}
|
||||
value={
|
||||
instance.enabled ? (
|
||||
<Progress
|
||||
title={t`Used capacity`}
|
||||
value={Math.round(
|
||||
100 - instance.percent_capacity_remaining
|
||||
)}
|
||||
measureLocation={ProgressMeasureLocation.top}
|
||||
size={ProgressSize.sm}
|
||||
aria-label={t`Used capacity`}
|
||||
/>
|
||||
) : (
|
||||
<Unavailable>{t`Unavailable`}</Unavailable>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{healthCheck?.errors && (
|
||||
<Detail
|
||||
fullWidth
|
||||
label={t`Errors`}
|
||||
value={
|
||||
<CodeBlock>
|
||||
<CodeBlockCode>{healthCheck?.errors}</CodeBlockCode>
|
||||
</CodeBlock>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</DetailList>
|
||||
{!isHopNode && (
|
||||
<CardActionsRow>
|
||||
<Tooltip content={t`Run a health check on the instance`}>
|
||||
<Button
|
||||
isDisabled={!me.is_superuser}
|
||||
variant="primary"
|
||||
ouiaId="health-check-button"
|
||||
onClick={fetchHealthCheck}
|
||||
>
|
||||
{t`Health Check`}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<InstanceToggle
|
||||
css="display: inline-flex;"
|
||||
fetchInstances={fetchDetails}
|
||||
instance={instance}
|
||||
/>
|
||||
</CardActionsRow>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<AlertModal
|
||||
isOpen={error}
|
||||
onClose={dismissError}
|
||||
title={t`Error!`}
|
||||
variant="error"
|
||||
>
|
||||
{updateInstanceError
|
||||
? t`Failed to update capacity adjustment.`
|
||||
: t`Failed to disassociate one or more instances.`}
|
||||
<ErrorDetail error={error} />
|
||||
</AlertModal>
|
||||
)}
|
||||
</CardBody>
|
||||
);
|
||||
}
|
||||
|
||||
export default InstanceDetail;
|
||||
@ -0,0 +1,222 @@
|
||||
import React from 'react';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import * as ConfigContext from 'contexts/Config';
|
||||
import useDebounce from 'hooks/useDebounce';
|
||||
import { InstancesAPI } from 'api';
|
||||
import {
|
||||
mountWithContexts,
|
||||
waitForElement,
|
||||
} from '../../../../testUtils/enzymeHelpers';
|
||||
import InstanceDetail from './InstanceDetail';
|
||||
|
||||
jest.mock('../../../api');
|
||||
jest.mock('../../../hooks/useDebounce');
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
useParams: () => ({
|
||||
id: 1,
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('<InstanceDetail/>', () => {
|
||||
let wrapper;
|
||||
beforeEach(() => {
|
||||
useDebounce.mockImplementation((fn) => fn);
|
||||
|
||||
InstancesAPI.readDetail.mockResolvedValue({
|
||||
data: {
|
||||
id: 1,
|
||||
type: 'instance',
|
||||
url: '/api/v2/instances/1/',
|
||||
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: 'hybrid',
|
||||
},
|
||||
});
|
||||
InstancesAPI.readHealthCheckDetail.mockResolvedValue({
|
||||
data: {
|
||||
uuid: '00000000-0000-0000-0000-000000000000',
|
||||
hostname: 'awx_1',
|
||||
version: '19.1.0',
|
||||
last_health_check: '2021-09-10T16:16:19.729676Z',
|
||||
errors: '',
|
||||
cpu: 8,
|
||||
memory: 6232231936,
|
||||
cpu_capacity: 32,
|
||||
mem_capacity: 38,
|
||||
capacity: 38,
|
||||
},
|
||||
});
|
||||
});
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
wrapper.unmount();
|
||||
});
|
||||
test('Should render proper data', async () => {
|
||||
jest.spyOn(ConfigContext, 'useConfig').mockImplementation(() => ({
|
||||
me: { is_superuser: true },
|
||||
}));
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(<InstanceDetail setBreadcrumb={() => {}} />);
|
||||
});
|
||||
await waitForElement(wrapper, 'ContentLoading', (el) => el.length === 0);
|
||||
expect(wrapper.find('InstanceDetail')).toHaveLength(1);
|
||||
|
||||
expect(InstancesAPI.readDetail).toBeCalledWith(1);
|
||||
expect(InstancesAPI.readHealthCheckDetail).toBeCalledWith(1);
|
||||
expect(
|
||||
wrapper.find("Button[ouiaId='health-check-button']").prop('isDisabled')
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test('should calculate number of forks when slide changes', async () => {
|
||||
jest.spyOn(ConfigContext, 'useConfig').mockImplementation(() => ({
|
||||
me: { is_superuser: true },
|
||||
}));
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(<InstanceDetail setBreadcrumb={() => {}} />);
|
||||
});
|
||||
await waitForElement(wrapper, 'ContentLoading', (el) => el.length === 0);
|
||||
|
||||
expect(wrapper.find('InstanceDetail').length).toBe(1);
|
||||
expect(wrapper.find('div[data-cy="number-forks"]').text()).toContain(
|
||||
'38 forks'
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
wrapper.find('Slider').prop('onChange')(4);
|
||||
});
|
||||
|
||||
wrapper.update();
|
||||
|
||||
expect(wrapper.find('div[data-cy="number-forks"]').text()).toContain(
|
||||
'56 forks'
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
wrapper.find('Slider').prop('onChange')(0);
|
||||
});
|
||||
wrapper.update();
|
||||
expect(wrapper.find('div[data-cy="number-forks"]').text()).toContain(
|
||||
'32 forks'
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
wrapper.find('Slider').prop('onChange')(0.5);
|
||||
});
|
||||
wrapper.update();
|
||||
expect(wrapper.find('div[data-cy="number-forks"]').text()).toContain(
|
||||
'35 forks'
|
||||
);
|
||||
});
|
||||
|
||||
test('buttons should be disabled', async () => {
|
||||
jest.spyOn(ConfigContext, 'useConfig').mockImplementation(() => ({
|
||||
me: { is_system_auditor: true },
|
||||
}));
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(<InstanceDetail setBreadcrumb={() => {}} />);
|
||||
});
|
||||
await waitForElement(wrapper, 'ContentLoading', (el) => el.length === 0);
|
||||
|
||||
expect(
|
||||
wrapper.find("Button[ouiaId='health-check-button']").prop('isDisabled')
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test('should display instance toggle', async () => {
|
||||
jest.spyOn(ConfigContext, 'useConfig').mockImplementation(() => ({
|
||||
me: { is_system_auditor: true },
|
||||
}));
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(<InstanceDetail setBreadcrumb={() => {}} />);
|
||||
});
|
||||
await waitForElement(wrapper, 'ContentLoading', (el) => el.length === 0);
|
||||
expect(wrapper.find('InstanceToggle').length).toBe(1);
|
||||
});
|
||||
|
||||
test('Should make request for Health Check', async () => {
|
||||
InstancesAPI.healthCheck.mockResolvedValue({
|
||||
data: {
|
||||
uuid: '00000000-0000-0000-0000-000000000000',
|
||||
hostname: 'awx_1',
|
||||
version: '19.1.0',
|
||||
last_health_check: '2021-09-15T18:02:07.270664Z',
|
||||
errors: '',
|
||||
cpu: 8,
|
||||
memory: 6232231936,
|
||||
cpu_capacity: 32,
|
||||
mem_capacity: 38,
|
||||
capacity: 38,
|
||||
},
|
||||
});
|
||||
jest.spyOn(ConfigContext, 'useConfig').mockImplementation(() => ({
|
||||
me: { is_superuser: true },
|
||||
}));
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(<InstanceDetail setBreadcrumb={() => {}} />);
|
||||
});
|
||||
await waitForElement(wrapper, 'ContentLoading', (el) => el.length === 0);
|
||||
expect(
|
||||
wrapper.find("Button[ouiaId='health-check-button']").prop('isDisabled')
|
||||
).toBe(false);
|
||||
await act(async () => {
|
||||
wrapper.find("Button[ouiaId='health-check-button']").prop('onClick')();
|
||||
});
|
||||
expect(InstancesAPI.healthCheck).toBeCalledWith(1);
|
||||
wrapper.update();
|
||||
expect(
|
||||
wrapper.find("Detail[label='Last Health Check']").prop('value')
|
||||
).toBe('9/15/2021, 6:02:07 PM');
|
||||
});
|
||||
|
||||
test('Should handle api error for health check', async () => {
|
||||
InstancesAPI.healthCheck.mockRejectedValue(
|
||||
new Error({
|
||||
response: {
|
||||
config: {
|
||||
method: 'post',
|
||||
url: '/api/v2/instances/1/health_check',
|
||||
},
|
||||
data: 'An error occurred',
|
||||
status: 403,
|
||||
},
|
||||
})
|
||||
);
|
||||
jest.spyOn(ConfigContext, 'useConfig').mockImplementation(() => ({
|
||||
me: { is_superuser: true },
|
||||
}));
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(<InstanceDetail setBreadcrumb={() => {}} />);
|
||||
});
|
||||
await waitForElement(wrapper, 'ContentLoading', (el) => el.length === 0);
|
||||
expect(
|
||||
wrapper.find("Button[ouiaId='health-check-button']").prop('isDisabled')
|
||||
).toBe(false);
|
||||
await act(async () => {
|
||||
wrapper.find("Button[ouiaId='health-check-button']").prop('onClick')();
|
||||
});
|
||||
expect(InstancesAPI.healthCheck).toBeCalledWith(1);
|
||||
wrapper.update();
|
||||
expect(wrapper.find('AlertModal')).toHaveLength(1);
|
||||
expect(wrapper.find('ErrorDetail')).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
1
awx/ui/src/screens/Instances/InstanceDetail/index.js
Normal file
1
awx/ui/src/screens/Instances/InstanceDetail/index.js
Normal file
@ -0,0 +1 @@
|
||||
export { default } from './InstanceDetail';
|
||||
189
awx/ui/src/screens/Instances/InstanceList/InstanceList.js
Normal file
189
awx/ui/src/screens/Instances/InstanceList/InstanceList.js
Normal file
@ -0,0 +1,189 @@
|
||||
import React, { useCallback, useEffect } from 'react';
|
||||
import { t } from '@lingui/macro';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import 'styled-components/macro';
|
||||
import { PageSection, Card } from '@patternfly/react-core';
|
||||
|
||||
import useExpanded from 'hooks/useExpanded';
|
||||
import DataListToolbar from 'components/DataListToolbar';
|
||||
import PaginatedTable, {
|
||||
HeaderRow,
|
||||
HeaderCell,
|
||||
getSearchableKeys,
|
||||
} from 'components/PaginatedTable';
|
||||
import AlertModal from 'components/AlertModal';
|
||||
import ErrorDetail from 'components/ErrorDetail';
|
||||
import useRequest, { useDismissableError } from 'hooks/useRequest';
|
||||
import useSelected from 'hooks/useSelected';
|
||||
import { InstancesAPI } from 'api';
|
||||
import { getQSConfig, parseQueryString } from 'util/qs';
|
||||
import HealthCheckButton from 'components/HealthCheckButton';
|
||||
import InstanceListItem from './InstanceListItem';
|
||||
|
||||
const QS_CONFIG = getQSConfig('instance', {
|
||||
page: 1,
|
||||
page_size: 20,
|
||||
order_by: 'hostname',
|
||||
});
|
||||
|
||||
function InstanceList() {
|
||||
const location = useLocation();
|
||||
|
||||
const {
|
||||
result: { instances, count, relatedSearchableKeys, searchableKeys },
|
||||
error: contentError,
|
||||
isLoading,
|
||||
request: fetchInstances,
|
||||
} = useRequest(
|
||||
useCallback(async () => {
|
||||
const params = parseQueryString(QS_CONFIG, location.search);
|
||||
const [response, responseActions] = await Promise.all([
|
||||
InstancesAPI.read(params),
|
||||
InstancesAPI.readOptions(),
|
||||
]);
|
||||
return {
|
||||
instances: response.data.results,
|
||||
count: response.data.count,
|
||||
actions: responseActions.data.actions,
|
||||
relatedSearchableKeys: (
|
||||
responseActions?.data?.related_search_fields || []
|
||||
).map((val) => val.slice(0, -8)),
|
||||
searchableKeys: getSearchableKeys(responseActions.data.actions?.GET),
|
||||
};
|
||||
}, [location.search]),
|
||||
{
|
||||
instances: [],
|
||||
count: 0,
|
||||
actions: {},
|
||||
relatedSearchableKeys: [],
|
||||
searchableKeys: [],
|
||||
}
|
||||
);
|
||||
|
||||
const { selected, isAllSelected, handleSelect, clearSelected, selectAll } =
|
||||
useSelected(instances.filter((i) => i.node_type !== 'hop'));
|
||||
|
||||
useEffect(() => {
|
||||
fetchInstances();
|
||||
}, [fetchInstances]);
|
||||
|
||||
const {
|
||||
error: healthCheckError,
|
||||
request: fetchHealthCheck,
|
||||
isLoading: isHealthCheckLoading,
|
||||
} = useRequest(
|
||||
useCallback(async () => {
|
||||
await Promise.all(
|
||||
selected
|
||||
.filter(({ node_type }) => node_type !== 'hop')
|
||||
.map(({ id }) => InstancesAPI.healthCheck(id))
|
||||
);
|
||||
fetchInstances();
|
||||
}, [selected, fetchInstances])
|
||||
);
|
||||
const handleHealthCheck = async () => {
|
||||
await fetchHealthCheck();
|
||||
clearSelected();
|
||||
};
|
||||
const { error, dismissError } = useDismissableError(healthCheckError);
|
||||
|
||||
const { expanded, isAllExpanded, handleExpand, expandAll } =
|
||||
useExpanded(instances);
|
||||
return (
|
||||
<>
|
||||
<PageSection>
|
||||
<Card>
|
||||
<PaginatedTable
|
||||
contentError={contentError}
|
||||
hasContentLoading={isLoading || isHealthCheckLoading}
|
||||
items={instances}
|
||||
itemCount={count}
|
||||
pluralizedItemName={t`Instances`}
|
||||
qsConfig={QS_CONFIG}
|
||||
clearSelected={clearSelected}
|
||||
toolbarSearchableKeys={searchableKeys}
|
||||
toolbarRelatedSearchableKeys={relatedSearchableKeys}
|
||||
toolbarSearchColumns={[
|
||||
{
|
||||
name: t`Name`,
|
||||
key: 'hostname__icontains',
|
||||
isDefault: true,
|
||||
},
|
||||
{
|
||||
name: t`Node Type`,
|
||||
key: `or__node_type`,
|
||||
options: [
|
||||
[`control`, t`Control`],
|
||||
[`execution`, t`Execution`],
|
||||
[`hybrid`, t`Hybrid`],
|
||||
[`hop`, t`Hop`],
|
||||
],
|
||||
},
|
||||
]}
|
||||
toolbarSortColumns={[
|
||||
{
|
||||
name: t`Name`,
|
||||
key: 'hostname',
|
||||
},
|
||||
]}
|
||||
renderToolbar={(props) => (
|
||||
<DataListToolbar
|
||||
{...props}
|
||||
isAllSelected={isAllSelected}
|
||||
onSelectAll={selectAll}
|
||||
isAllExpanded={isAllExpanded}
|
||||
onExpandAll={expandAll}
|
||||
qsConfig={QS_CONFIG}
|
||||
additionalControls={[
|
||||
<HealthCheckButton
|
||||
onClick={handleHealthCheck}
|
||||
selectedItems={selected}
|
||||
/>,
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
headerRow={
|
||||
<HeaderRow qsConfig={QS_CONFIG} isExpandable>
|
||||
<HeaderCell
|
||||
tooltip={t`Cannot run health check on hop 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>
|
||||
<HeaderCell>{t`Used Capacity`}</HeaderCell>
|
||||
<HeaderCell>{t`Actions`}</HeaderCell>
|
||||
</HeaderRow>
|
||||
}
|
||||
renderRow={(instance, index) => (
|
||||
<InstanceListItem
|
||||
isExpanded={expanded.some((row) => row.id === instance.id)}
|
||||
onExpand={() => handleExpand(instance)}
|
||||
key={instance.id}
|
||||
value={instance.hostname}
|
||||
instance={instance}
|
||||
onSelect={() => handleSelect(instance)}
|
||||
isSelected={selected.some((row) => row.id === instance.id)}
|
||||
fetchInstances={fetchInstances}
|
||||
rowIndex={index}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Card>
|
||||
</PageSection>
|
||||
{error && (
|
||||
<AlertModal
|
||||
isOpen={error}
|
||||
onClose={dismissError}
|
||||
title={t`Error!`}
|
||||
variant="error"
|
||||
>
|
||||
{t`Failed to run a health check on one or more instances.`}
|
||||
<ErrorDetail error={error} />
|
||||
</AlertModal>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default InstanceList;
|
||||
193
awx/ui/src/screens/Instances/InstanceList/InstanceList.test.js
Normal file
193
awx/ui/src/screens/Instances/InstanceList/InstanceList.test.js
Normal file
@ -0,0 +1,193 @@
|
||||
import React from 'react';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import { Route } from 'react-router-dom';
|
||||
import { createMemoryHistory } from 'history';
|
||||
|
||||
import { InstancesAPI } from 'api';
|
||||
import {
|
||||
mountWithContexts,
|
||||
waitForElement,
|
||||
} from '../../../../testUtils/enzymeHelpers';
|
||||
|
||||
import InstanceList from './InstanceList';
|
||||
|
||||
jest.mock('../../../api');
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
useParams: () => ({
|
||||
id: 1,
|
||||
}),
|
||||
}));
|
||||
|
||||
const instances = [
|
||||
{
|
||||
id: 1,
|
||||
type: 'instance',
|
||||
url: '/api/v2/instances/1/',
|
||||
capacity_adjustment: '0.40',
|
||||
version: '13.0.0',
|
||||
capacity: 10,
|
||||
consumed_capacity: 0,
|
||||
percent_capacity_remaining: 60.0,
|
||||
jobs_running: 0,
|
||||
jobs_total: 68,
|
||||
cpu: 6,
|
||||
node_type: 'control',
|
||||
memory: 2087469056,
|
||||
cpu_capacity: 24,
|
||||
mem_capacity: 1,
|
||||
enabled: true,
|
||||
managed_by_policy: true,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
type: 'instance',
|
||||
url: '/api/v2/instances/2/',
|
||||
capacity_adjustment: '0.40',
|
||||
version: '13.0.0',
|
||||
capacity: 10,
|
||||
consumed_capacity: 0,
|
||||
percent_capacity_remaining: 60.0,
|
||||
jobs_running: 0,
|
||||
jobs_total: 68,
|
||||
cpu: 6,
|
||||
node_type: 'hybrid',
|
||||
memory: 2087469056,
|
||||
cpu_capacity: 24,
|
||||
mem_capacity: 1,
|
||||
enabled: true,
|
||||
managed_by_policy: false,
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
type: 'instance',
|
||||
url: '/api/v2/instances/3/',
|
||||
capacity_adjustment: '0.40',
|
||||
version: '13.0.0',
|
||||
capacity: 10,
|
||||
consumed_capacity: 0,
|
||||
percent_capacity_remaining: 60.0,
|
||||
jobs_running: 0,
|
||||
jobs_total: 68,
|
||||
cpu: 6,
|
||||
node_type: 'execution',
|
||||
memory: 2087469056,
|
||||
cpu_capacity: 24,
|
||||
mem_capacity: 1,
|
||||
enabled: false,
|
||||
managed_by_policy: true,
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
type: 'instance',
|
||||
url: '/api/v2/instances/4/',
|
||||
capacity_adjustment: '0.40',
|
||||
version: '13.0.0',
|
||||
capacity: 10,
|
||||
consumed_capacity: 0,
|
||||
percent_capacity_remaining: 60.0,
|
||||
jobs_running: 0,
|
||||
jobs_total: 68,
|
||||
cpu: 6,
|
||||
node_type: 'hop',
|
||||
memory: 2087469056,
|
||||
cpu_capacity: 24,
|
||||
mem_capacity: 1,
|
||||
enabled: false,
|
||||
managed_by_policy: true,
|
||||
},
|
||||
];
|
||||
|
||||
describe('<InstanceList/>', () => {
|
||||
let wrapper;
|
||||
|
||||
const options = { data: { actions: { POST: true } } };
|
||||
|
||||
beforeEach(async () => {
|
||||
InstancesAPI.read.mockResolvedValue({
|
||||
data: {
|
||||
count: instances.length,
|
||||
results: instances,
|
||||
},
|
||||
});
|
||||
InstancesAPI.readOptions.mockResolvedValue(options);
|
||||
const history = createMemoryHistory({
|
||||
initialEntries: ['/instances/1'],
|
||||
});
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(
|
||||
<Route path="/instances/:id">
|
||||
<InstanceList />
|
||||
</Route>,
|
||||
{
|
||||
context: {
|
||||
router: { history, route: { location: history.location } },
|
||||
},
|
||||
}
|
||||
);
|
||||
});
|
||||
await waitForElement(wrapper, 'ContentLoading', (el) => el.length === 0);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
test('should have data fetched', () => {
|
||||
expect(wrapper.find('InstanceList').length).toBe(1);
|
||||
});
|
||||
|
||||
test('should fetch instances from the api and render them in the list', () => {
|
||||
expect(InstancesAPI.read).toHaveBeenCalled();
|
||||
expect(InstancesAPI.readOptions).toHaveBeenCalled();
|
||||
expect(wrapper.find('InstanceListItem').length).toBe(4);
|
||||
});
|
||||
|
||||
test('Should run health check', async () => {
|
||||
// Ensures health check button is disabled on mount
|
||||
expect(
|
||||
wrapper.find('Button[ouiaId="health-check"]').prop('isDisabled')
|
||||
).toBe(true);
|
||||
await act(async () =>
|
||||
wrapper.find('DataListToolbar').prop('onSelectAll')(instances)
|
||||
);
|
||||
wrapper.update();
|
||||
await act(async () =>
|
||||
wrapper.find('input[aria-label="Select row 3"]').prop('onChange')(false)
|
||||
);
|
||||
wrapper.update();
|
||||
await act(async () =>
|
||||
wrapper.find('Button[ouiaId="health-check"]').prop('onClick')()
|
||||
);
|
||||
expect(InstancesAPI.healthCheck).toBeCalledTimes(3);
|
||||
});
|
||||
test('Should render health check error', async () => {
|
||||
InstancesAPI.healthCheck.mockRejectedValue(
|
||||
new Error({
|
||||
response: {
|
||||
config: {
|
||||
method: 'create',
|
||||
url: '/api/v2/instances',
|
||||
},
|
||||
data: 'An error occurred',
|
||||
status: 403,
|
||||
},
|
||||
})
|
||||
);
|
||||
expect(
|
||||
wrapper.find('Button[ouiaId="health-check"]').prop('isDisabled')
|
||||
).toBe(true);
|
||||
await act(async () =>
|
||||
wrapper.find('input[aria-label="Select row 1"]').prop('onChange')(true)
|
||||
);
|
||||
wrapper.update();
|
||||
expect(
|
||||
wrapper.find('Button[ouiaId="health-check"]').prop('isDisabled')
|
||||
).toBe(false);
|
||||
await act(async () =>
|
||||
wrapper.find('Button[ouiaId="health-check"]').prop('onClick')()
|
||||
);
|
||||
wrapper.update();
|
||||
expect(wrapper.find('AlertModal')).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
252
awx/ui/src/screens/Instances/InstanceList/InstanceListItem.js
Normal file
252
awx/ui/src/screens/Instances/InstanceList/InstanceListItem.js
Normal file
@ -0,0 +1,252 @@
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { bool, func } from 'prop-types';
|
||||
import { t, Plural } from '@lingui/macro';
|
||||
import styled from 'styled-components';
|
||||
import 'styled-components/macro';
|
||||
import {
|
||||
Progress,
|
||||
ProgressMeasureLocation,
|
||||
ProgressSize,
|
||||
Slider,
|
||||
Tooltip,
|
||||
} from '@patternfly/react-core';
|
||||
import { Tr, Td, ExpandableRowContent } from '@patternfly/react-table';
|
||||
import { formatDateString } from 'util/dates';
|
||||
import computeForks from 'util/computeForks';
|
||||
import { ActionsTd, ActionItem } from 'components/PaginatedTable';
|
||||
import InstanceToggle from 'components/InstanceToggle';
|
||||
import StatusLabel from 'components/StatusLabel';
|
||||
import { Instance } from 'types';
|
||||
import useRequest, { useDismissableError } from 'hooks/useRequest';
|
||||
import useDebounce from 'hooks/useDebounce';
|
||||
import { InstancesAPI } from 'api';
|
||||
import { useConfig } from 'contexts/Config';
|
||||
import AlertModal from 'components/AlertModal';
|
||||
import ErrorDetail from 'components/ErrorDetail';
|
||||
import { Detail, DetailList } from 'components/DetailList';
|
||||
|
||||
const Unavailable = styled.span`
|
||||
color: var(--pf-global--danger-color--200);
|
||||
`;
|
||||
|
||||
const SliderHolder = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
`;
|
||||
|
||||
const SliderForks = styled.div`
|
||||
flex-grow: 1;
|
||||
margin-right: 8px;
|
||||
margin-left: 8px;
|
||||
text-align: center;
|
||||
`;
|
||||
|
||||
function InstanceListItem({
|
||||
instance,
|
||||
isExpanded,
|
||||
onExpand,
|
||||
isSelected,
|
||||
onSelect,
|
||||
fetchInstances,
|
||||
rowIndex,
|
||||
}) {
|
||||
const { me = {} } = useConfig();
|
||||
const [forks, setForks] = useState(
|
||||
computeForks(
|
||||
instance.mem_capacity,
|
||||
instance.cpu_capacity,
|
||||
instance.capacity_adjustment
|
||||
)
|
||||
);
|
||||
|
||||
const labelId = `check-action-${instance.id}`;
|
||||
|
||||
function usedCapacity(item) {
|
||||
if (item.enabled) {
|
||||
return (
|
||||
<Progress
|
||||
value={Math.round(100 - item.percent_capacity_remaining)}
|
||||
measureLocation={ProgressMeasureLocation.top}
|
||||
size={ProgressSize.sm}
|
||||
title={t`Used capacity`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return <Unavailable>{t`Unavailable`}</Unavailable>;
|
||||
}
|
||||
|
||||
const { error: updateInstanceError, request: updateInstance } = useRequest(
|
||||
useCallback(
|
||||
async (values) => {
|
||||
await InstancesAPI.update(instance.id, values);
|
||||
},
|
||||
[instance]
|
||||
)
|
||||
);
|
||||
|
||||
const { error: updateError, dismissError: dismissUpdateError } =
|
||||
useDismissableError(updateInstanceError);
|
||||
|
||||
const debounceUpdateInstance = useDebounce(updateInstance, 200);
|
||||
|
||||
const handleChangeValue = (value) => {
|
||||
const roundedValue = Math.round(value * 100) / 100;
|
||||
setForks(
|
||||
computeForks(instance.mem_capacity, instance.cpu_capacity, roundedValue)
|
||||
);
|
||||
debounceUpdateInstance({ capacity_adjustment: roundedValue });
|
||||
};
|
||||
const isHopNode = instance.node_type === 'hop';
|
||||
return (
|
||||
<>
|
||||
<Tr
|
||||
id={`instance-row-${instance.id}`}
|
||||
ouiaId={`instance-row-${instance.id}`}
|
||||
>
|
||||
{isHopNode ? (
|
||||
<Td />
|
||||
) : (
|
||||
<Td
|
||||
expand={{
|
||||
rowIndex,
|
||||
isExpanded,
|
||||
onToggle: onExpand,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<Td
|
||||
select={{
|
||||
rowIndex,
|
||||
isSelected,
|
||||
onSelect,
|
||||
disable: isHopNode,
|
||||
}}
|
||||
dataLabel={t`Selected`}
|
||||
/>
|
||||
<Td id={labelId} dataLabel={t`Name`}>
|
||||
<Link to={`/instances/${instance.id}/details`}>
|
||||
<b>{instance.hostname}</b>
|
||||
</Link>
|
||||
</Td>
|
||||
|
||||
<Td dataLabel={t`Status`}>
|
||||
<Tooltip
|
||||
content={
|
||||
<div>
|
||||
{t`Last Health Check`}
|
||||
|
||||
{formatDateString(
|
||||
instance.last_health_check ?? instance.last_seen
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<StatusLabel status={instance.errors ? 'error' : 'healthy'} />
|
||||
</Tooltip>
|
||||
</Td>
|
||||
|
||||
<Td dataLabel={t`Node Type`}>{instance.node_type}</Td>
|
||||
{!isHopNode && (
|
||||
<>
|
||||
<Td dataLabel={t`Capacity Adjustment`}>
|
||||
<SliderHolder data-cy="slider-holder">
|
||||
<div data-cy="cpu-capacity">{t`CPU ${instance.cpu_capacity}`}</div>
|
||||
<SliderForks data-cy="slider-forks">
|
||||
<div data-cy="number-forks">
|
||||
<Plural value={forks} one="# fork" other="# forks" />
|
||||
</div>
|
||||
<Slider
|
||||
areCustomStepsContinuous
|
||||
max={1}
|
||||
min={0}
|
||||
step={0.1}
|
||||
value={instance.capacity_adjustment}
|
||||
onChange={handleChangeValue}
|
||||
isDisabled={!me?.is_superuser || !instance.enabled}
|
||||
data-cy="slider"
|
||||
/>
|
||||
</SliderForks>
|
||||
<div data-cy="mem-capacity">{t`RAM ${instance.mem_capacity}`}</div>
|
||||
</SliderHolder>
|
||||
</Td>
|
||||
|
||||
<Td
|
||||
dataLabel={t`Instance group used capacity`}
|
||||
css="--pf-c-table--cell--MinWidth: 175px;"
|
||||
>
|
||||
{usedCapacity(instance)}
|
||||
</Td>
|
||||
|
||||
<ActionsTd
|
||||
dataLabel={t`Actions`}
|
||||
css="--pf-c-table--cell--Width: 125px"
|
||||
>
|
||||
<ActionItem visible>
|
||||
<InstanceToggle
|
||||
css="display: inline-flex;"
|
||||
fetchInstances={fetchInstances}
|
||||
instance={instance}
|
||||
/>
|
||||
</ActionItem>
|
||||
</ActionsTd>
|
||||
</>
|
||||
)}
|
||||
</Tr>
|
||||
{!isHopNode && (
|
||||
<Tr
|
||||
ouiaId={`instance-row-${instance.id}-expanded`}
|
||||
isExpanded={isExpanded}
|
||||
>
|
||||
<Td colSpan={2} />
|
||||
<Td colSpan={7}>
|
||||
<ExpandableRowContent>
|
||||
<DetailList>
|
||||
<Detail
|
||||
data-cy="running-jobs"
|
||||
value={instance.jobs_running}
|
||||
label={t`Running Jobs`}
|
||||
/>
|
||||
<Detail
|
||||
data-cy="total-jobs"
|
||||
value={instance.jobs_total}
|
||||
label={t`Total Jobs`}
|
||||
/>
|
||||
<Detail
|
||||
data-cy="policy-type"
|
||||
label={t`Policy Type`}
|
||||
value={instance.managed_by_policy ? t`Auto` : t`Manual`}
|
||||
/>
|
||||
<Detail
|
||||
data-cy="last-health-check"
|
||||
label={t`Last Health Check`}
|
||||
value={formatDateString(instance.last_health_check)}
|
||||
/>
|
||||
</DetailList>
|
||||
</ExpandableRowContent>
|
||||
</Td>
|
||||
</Tr>
|
||||
)}
|
||||
{updateError && (
|
||||
<AlertModal
|
||||
variant="error"
|
||||
title={t`Error!`}
|
||||
isOpen
|
||||
onClose={dismissUpdateError}
|
||||
>
|
||||
{t`Failed to update capacity adjustment.`}
|
||||
<ErrorDetail error={updateError} />
|
||||
</AlertModal>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
InstanceListItem.prototype = {
|
||||
instance: Instance.isRequired,
|
||||
isSelected: bool.isRequired,
|
||||
onSelect: func.isRequired,
|
||||
};
|
||||
|
||||
export default InstanceListItem;
|
||||
@ -0,0 +1,298 @@
|
||||
import React from 'react';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
|
||||
import { InstancesAPI } from 'api';
|
||||
import useDebounce from 'hooks/useDebounce';
|
||||
import { mountWithContexts } from '../../../../testUtils/enzymeHelpers';
|
||||
|
||||
import InstanceListItem from './InstanceListItem';
|
||||
|
||||
jest.mock('../../../api');
|
||||
jest.mock('../../../hooks/useDebounce');
|
||||
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
useParams: () => ({
|
||||
id: 1,
|
||||
}),
|
||||
}));
|
||||
const instance = [
|
||||
{
|
||||
id: 1,
|
||||
type: 'instance',
|
||||
url: '/api/v2/instances/1/',
|
||||
uuid: '00000000-0000-0000-0000-000000000000',
|
||||
hostname: 'awx',
|
||||
created: '2020-07-14T19:03:49.000054Z',
|
||||
modified: '2020-08-12T20:08:02.836748Z',
|
||||
capacity_adjustment: '0.40',
|
||||
version: '13.0.0',
|
||||
capacity: 10,
|
||||
consumed_capacity: 0,
|
||||
percent_capacity_remaining: 60.0,
|
||||
jobs_running: 0,
|
||||
jobs_total: 68,
|
||||
last_health_check: '2021-09-15T18:02:07.270664Z',
|
||||
cpu: 6,
|
||||
memory: 2087469056,
|
||||
cpu_capacity: 24,
|
||||
mem_capacity: 1,
|
||||
enabled: true,
|
||||
managed_by_policy: true,
|
||||
node_type: 'hybrid',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
type: 'instance',
|
||||
url: '/api/v2/instances/1/',
|
||||
uuid: '00000000-0000-0000-0000-000000000001',
|
||||
hostname: 'awx-control',
|
||||
created: '2020-07-14T19:03:49.000054Z',
|
||||
modified: '2020-08-12T20:08:02.836748Z',
|
||||
capacity_adjustment: '0.40',
|
||||
version: '13.0.0',
|
||||
last_health_check: '2021-09-15T18:02:07.270664Z',
|
||||
capacity: 10,
|
||||
consumed_capacity: 0,
|
||||
percent_capacity_remaining: 60.0,
|
||||
jobs_running: 0,
|
||||
jobs_total: 68,
|
||||
cpu: 6,
|
||||
memory: 2087469056,
|
||||
cpu_capacity: 24,
|
||||
mem_capacity: 1,
|
||||
enabled: true,
|
||||
managed_by_policy: true,
|
||||
node_type: 'hop',
|
||||
},
|
||||
];
|
||||
|
||||
describe('<InstanceListItem/>', () => {
|
||||
let wrapper;
|
||||
|
||||
beforeEach(() => {
|
||||
useDebounce.mockImplementation((fn) => fn);
|
||||
});
|
||||
|
||||
test('should mount successfully', async () => {
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(
|
||||
<table>
|
||||
<tbody>
|
||||
<InstanceListItem
|
||||
instance={instance[0]}
|
||||
isSelected={false}
|
||||
onSelect={() => {}}
|
||||
fetchInstances={() => {}}
|
||||
/>
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
});
|
||||
expect(wrapper.find('InstanceListItem').length).toBe(1);
|
||||
});
|
||||
|
||||
test('should calculate number of forks when slide changes', async () => {
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(
|
||||
<table>
|
||||
<tbody>
|
||||
<InstanceListItem
|
||||
instance={instance[0]}
|
||||
isSelected={false}
|
||||
onSelect={() => {}}
|
||||
fetchInstances={() => {}}
|
||||
/>
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
});
|
||||
expect(wrapper.find('InstanceListItem').length).toBe(1);
|
||||
expect(wrapper.find('InstanceListItem__SliderForks').text()).toContain(
|
||||
'10 forks'
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
wrapper.find('Slider').prop('onChange')(1);
|
||||
});
|
||||
|
||||
wrapper.update();
|
||||
expect(wrapper.find('InstanceListItem__SliderForks').text()).toContain(
|
||||
'24 forks'
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
wrapper.find('Slider').prop('onChange')(0);
|
||||
});
|
||||
wrapper.update();
|
||||
expect(wrapper.find('InstanceListItem__SliderForks').text()).toContain(
|
||||
'1 fork'
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
wrapper.find('Slider').prop('onChange')(0.5);
|
||||
});
|
||||
wrapper.update();
|
||||
expect(wrapper.find('InstanceListItem__SliderForks').text()).toContain(
|
||||
'12 forks'
|
||||
);
|
||||
});
|
||||
|
||||
test('should render the proper data instance', async () => {
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(
|
||||
<table>
|
||||
<tbody>
|
||||
<InstanceListItem
|
||||
instance={instance[0]}
|
||||
isSelected={false}
|
||||
onSelect={() => {}}
|
||||
fetchInstances={() => {}}
|
||||
/>
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
});
|
||||
expect(wrapper.find('Td[dataLabel="Name"]').find('Link').prop('to')).toBe(
|
||||
'/instances/1/details'
|
||||
);
|
||||
expect(wrapper.find('Td').at(2).text()).toBe('awx');
|
||||
expect(wrapper.find('Progress').prop('value')).toBe(40);
|
||||
expect(
|
||||
wrapper
|
||||
.find('Td')
|
||||
.at(5)
|
||||
.containsMatchingElement(<div>CPU 24</div>)
|
||||
);
|
||||
expect(
|
||||
wrapper
|
||||
.find('Td')
|
||||
.at(5)
|
||||
.containsMatchingElement(<div>RAM 24</div>)
|
||||
);
|
||||
expect(wrapper.find('InstanceListItem__SliderForks').text()).toContain(
|
||||
'10 forks'
|
||||
);
|
||||
});
|
||||
|
||||
test('should render checkbox', async () => {
|
||||
const onSelect = jest.fn();
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(
|
||||
<table>
|
||||
<tbody>
|
||||
<InstanceListItem
|
||||
instance={instance[0]}
|
||||
onSelect={onSelect}
|
||||
fetchInstances={() => {}}
|
||||
/>
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
});
|
||||
expect(wrapper.find('Td').at(1).prop('select').onSelect).toEqual(onSelect);
|
||||
});
|
||||
|
||||
test('should display instance toggle', () => {
|
||||
expect(wrapper.find('InstanceToggle').length).toBe(1);
|
||||
});
|
||||
|
||||
test('should display error', async () => {
|
||||
jest.useFakeTimers();
|
||||
InstancesAPI.update.mockRejectedValue(
|
||||
new Error({
|
||||
response: {
|
||||
config: {
|
||||
method: 'patch',
|
||||
url: '/api/v2/instances/1',
|
||||
data: { capacity_adjustment: 0.30001 },
|
||||
},
|
||||
data: {
|
||||
capacity_adjustment: [
|
||||
'Ensure that there are no more than 3 digits in total.',
|
||||
],
|
||||
},
|
||||
status: 400,
|
||||
statusText: 'Bad Request',
|
||||
},
|
||||
})
|
||||
);
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(
|
||||
<table>
|
||||
<tbody>
|
||||
<InstanceListItem
|
||||
instance={instance[0]}
|
||||
isSelected={false}
|
||||
onSelect={() => {}}
|
||||
fetchInstances={() => {}}
|
||||
/>
|
||||
</tbody>
|
||||
</table>,
|
||||
{ context: { network: { handleHttpError: () => {} } } }
|
||||
);
|
||||
});
|
||||
await act(async () => {
|
||||
wrapper.update();
|
||||
});
|
||||
expect(wrapper.find('ErrorDetail').length).toBe(0);
|
||||
await act(async () => {
|
||||
wrapper.find('Slider').prop('onChange')(0.30001);
|
||||
});
|
||||
await act(async () => {
|
||||
wrapper.update();
|
||||
});
|
||||
jest.advanceTimersByTime(210);
|
||||
await act(async () => {
|
||||
wrapper.update();
|
||||
});
|
||||
expect(wrapper.find('ErrorDetail').length).toBe(1);
|
||||
});
|
||||
|
||||
test('Should render expanded row with the correct data points', async () => {
|
||||
const onSelect = jest.fn();
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(
|
||||
<table>
|
||||
<tbody>
|
||||
<InstanceListItem
|
||||
instance={instance[0]}
|
||||
onSelect={onSelect}
|
||||
fetchInstances={() => {}}
|
||||
isExpanded
|
||||
/>
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
});
|
||||
expect(wrapper.find('InstanceListItem').prop('isExpanded')).toBe(true);
|
||||
|
||||
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');
|
||||
});
|
||||
test('Hop should not render some things', async () => {
|
||||
const onSelect = jest.fn();
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(
|
||||
<table>
|
||||
<tbody>
|
||||
<InstanceListItem
|
||||
instance={instance[1]}
|
||||
onSelect={onSelect}
|
||||
fetchInstances={() => {}}
|
||||
/>
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
});
|
||||
expect(wrapper.find('InstanceToggle').length).toBe(0);
|
||||
expect(
|
||||
wrapper.find("Td[dataLabel='Instance group used capacity']").length
|
||||
).toBe(0);
|
||||
expect(wrapper.find("Td[dataLabel='Capacity Adjustment']").length).toBe(0);
|
||||
});
|
||||
});
|
||||
2
awx/ui/src/screens/Instances/InstanceList/index.js
Normal file
2
awx/ui/src/screens/Instances/InstanceList/index.js
Normal file
@ -0,0 +1,2 @@
|
||||
export { default as InstanceList } from './InstanceList';
|
||||
export { default as InstanceListItem } from './InstanceListItem';
|
||||
40
awx/ui/src/screens/Instances/Instances.js
Normal file
40
awx/ui/src/screens/Instances/Instances.js
Normal file
@ -0,0 +1,40 @@
|
||||
import React, { useCallback, useState } from 'react';
|
||||
|
||||
import { t } from '@lingui/macro';
|
||||
import { Route, Switch } from 'react-router-dom';
|
||||
import ScreenHeader from 'components/ScreenHeader';
|
||||
import { InstanceList } from './InstanceList';
|
||||
import Instance from './Instance';
|
||||
|
||||
function Instances() {
|
||||
const [breadcrumbConfig, setBreadcrumbConfig] = useState({
|
||||
'/instances': t`Instances`,
|
||||
});
|
||||
|
||||
const buildBreadcrumbConfig = useCallback((instance) => {
|
||||
if (!instance) {
|
||||
return;
|
||||
}
|
||||
setBreadcrumbConfig({
|
||||
'/instances': t`Instances`,
|
||||
[`/instances/${instance.id}`]: `${instance.hostname}`,
|
||||
[`/instances/${instance.id}/details`]: t`Details`,
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ScreenHeader streamType="instance" breadcrumbConfig={breadcrumbConfig} />
|
||||
<Switch>
|
||||
<Route path="/instances/:id">
|
||||
<Instance setBreadcrumb={buildBreadcrumbConfig} />
|
||||
</Route>
|
||||
<Route path="/instances">
|
||||
<InstanceList />
|
||||
</Route>
|
||||
</Switch>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default Instances;
|
||||
1
awx/ui/src/screens/Instances/index.js
Normal file
1
awx/ui/src/screens/Instances/index.js
Normal file
@ -0,0 +1 @@
|
||||
export { default } from './Instances';
|
||||
12
awx/ui/src/util/computeForks.js
Normal file
12
awx/ui/src/util/computeForks.js
Normal file
@ -0,0 +1,12 @@
|
||||
export default function computeForks(
|
||||
memCapacity,
|
||||
cpuCapacity,
|
||||
selectedCapacityAdjustment
|
||||
) {
|
||||
const minCapacity = Math.min(memCapacity, cpuCapacity);
|
||||
const maxCapacity = Math.max(memCapacity, cpuCapacity);
|
||||
|
||||
return Math.floor(
|
||||
minCapacity + (maxCapacity - minCapacity) * selectedCapacityAdjustment
|
||||
);
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user