Merge pull request #13712 from ansible/feature_usage-collection

Allow soft deletion of HostMetrics and add usage collection utility
This commit is contained in:
Hao Liu
2023-03-28 12:16:02 -04:00
committed by GitHub
33 changed files with 1259 additions and 205 deletions

View File

@@ -44,6 +44,7 @@ import WorkflowApprovalTemplates from './models/WorkflowApprovalTemplates';
import WorkflowJobTemplateNodes from './models/WorkflowJobTemplateNodes';
import WorkflowJobTemplates from './models/WorkflowJobTemplates';
import WorkflowJobs from './models/WorkflowJobs';
import HostMetrics from './models/HostMetrics';
const ActivityStreamAPI = new ActivityStream();
const AdHocCommandsAPI = new AdHocCommands();
@@ -91,6 +92,7 @@ const WorkflowApprovalTemplatesAPI = new WorkflowApprovalTemplates();
const WorkflowJobTemplateNodesAPI = new WorkflowJobTemplateNodes();
const WorkflowJobTemplatesAPI = new WorkflowJobTemplates();
const WorkflowJobsAPI = new WorkflowJobs();
const HostMetricsAPI = new HostMetrics();
export {
ActivityStreamAPI,
@@ -139,4 +141,5 @@ export {
WorkflowJobTemplateNodesAPI,
WorkflowJobTemplatesAPI,
WorkflowJobsAPI,
HostMetricsAPI,
};

View File

@@ -0,0 +1,10 @@
import Base from '../Base';
class HostMetrics extends Base {
constructor(http) {
super(http);
this.baseUrl = 'api/v2/host_metrics/';
}
}
export default HostMetrics;

View File

@@ -18,6 +18,10 @@ class Settings extends Base {
return this.http.get(`${this.baseUrl}all/`);
}
readSystem() {
return this.http.get(`${this.baseUrl}system/`);
}
updateCategory(category, data) {
return this.http.patch(`${this.baseUrl}${category}/`, data);
}

View File

@@ -57,6 +57,7 @@ function DataListToolbar({
enableRelatedFuzzyFiltering,
handleIsAnsibleFactsSelected,
isFilterCleared,
advancedSearchDisabled,
}) {
const showExpandCollapse = onCompact && onExpand;
const [isKebabOpen, setIsKebabOpen] = useState(false);
@@ -86,6 +87,10 @@ function DataListToolbar({
}),
[setIsKebabModalOpen]
);
const columns = [...searchColumns];
if (!advancedSearchDisabled) {
columns.push({ name: t`Advanced`, key: 'advanced' });
}
return (
<Toolbar
id={`${qsConfig.namespace}-list-toolbar`}
@@ -134,10 +139,7 @@ function DataListToolbar({
<ToolbarItem>
<Search
qsConfig={qsConfig}
columns={[
...searchColumns,
{ name: t`Advanced`, key: 'advanced' },
]}
columns={columns}
searchableKeys={searchableKeys}
relatedSearchableKeys={relatedSearchableKeys}
onSearch={onSearch}
@@ -224,6 +226,7 @@ DataListToolbar.propTypes = {
additionalControls: PropTypes.arrayOf(PropTypes.node),
enableNegativeFiltering: PropTypes.bool,
enableRelatedFuzzyFiltering: PropTypes.bool,
advancedSearchDisabled: PropTypes.bool,
};
DataListToolbar.defaultProps = {
@@ -243,6 +246,7 @@ DataListToolbar.defaultProps = {
additionalControls: [],
enableNegativeFiltering: true,
enableRelatedFuzzyFiltering: true,
advancedSearchDisabled: false,
};
export default DataListToolbar;

View File

@@ -8,6 +8,7 @@ import useRequest, { useDismissableError } from 'hooks/useRequest';
import AlertModal from 'components/AlertModal';
import ErrorDetail from 'components/ErrorDetail';
import { useSession } from './Session';
import { SettingsAPI } from '../api';
// eslint-disable-next-line import/prefer-default-export
export const ConfigContext = React.createContext({});
@@ -40,6 +41,11 @@ export const ConfigProvider = ({ children }) => {
},
},
] = await Promise.all([ConfigAPI.read(), MeAPI.read()]);
let systemConfig = {};
if (me?.is_superuser || me?.is_system_auditor) {
const { data: systemConfigResults } = await SettingsAPI.readSystem();
systemConfig = systemConfigResults;
}
const [
{
@@ -62,10 +68,21 @@ export const ConfigProvider = ({ children }) => {
role_level: 'execution_environment_admin_role',
}),
]);
return { ...data, me, adminOrgCount, notifAdminCount, execEnvAdminCount };
return {
...data,
me,
adminOrgCount,
notifAdminCount,
execEnvAdminCount,
systemConfig,
};
}, []),
{ adminOrgCount: 0, notifAdminCount: 0, execEnvAdminCount: 0 }
{
adminOrgCount: 0,
notifAdminCount: 0,
execEnvAdminCount: 0,
systemConfig: {},
}
);
const { error, dismissError } = useDismissableError(configError);
@@ -112,6 +129,7 @@ export const useUserProfile = () => {
isOrgAdmin: config.adminOrgCount,
isNotificationAdmin: config.notifAdminCount,
isExecEnvAdmin: config.execEnvAdminCount,
systemConfig: config.systemConfig,
};
};

View File

@@ -23,6 +23,7 @@ import TopologyView from 'screens/TopologyView';
import Users from 'screens/User';
import WorkflowApprovals from 'screens/WorkflowApproval';
import { Jobs } from 'screens/Job';
import HostMetrics from 'screens/HostMetrics';
function getRouteConfig(userProfile = {}) {
let routeConfig = [
@@ -55,6 +56,11 @@ function getRouteConfig(userProfile = {}) {
path: '/workflow_approvals',
screen: WorkflowApprovals,
},
{
title: <Trans>Host Metrics</Trans>,
path: '/host_metrics',
screen: HostMetrics,
},
],
},
{
@@ -178,9 +184,15 @@ function getRouteConfig(userProfile = {}) {
const deleteRouteGroup = (name) => {
routeConfig = routeConfig.filter(({ groupId }) => !groupId.includes(name));
};
if (
userProfile?.systemConfig?.SUBSCRIPTION_USAGE_MODEL !==
'unique_managed_hosts'
) {
deleteRoute('host_metrics');
}
if (userProfile?.isSuperUser || userProfile?.isSystemAuditor)
return routeConfig;
deleteRoute('host_metrics');
deleteRouteGroup('settings');
deleteRoute('management_jobs');
if (userProfile?.isOrgAdmin) return routeConfig;

View File

@@ -7,6 +7,7 @@ const userProfile = {
isOrgAdmin: false,
isNotificationAdmin: false,
isExecEnvAdmin: false,
systemConfig: { SUBSCRIPTION_USAGE_MODEL: 'unique_managed_hosts' },
};
const filterPaths = (sidebar) => {
@@ -29,6 +30,7 @@ describe('getRouteConfig', () => {
'/schedules',
'/activity_stream',
'/workflow_approvals',
'/host_metrics',
'/templates',
'/credentials',
'/projects',
@@ -58,6 +60,7 @@ describe('getRouteConfig', () => {
'/schedules',
'/activity_stream',
'/workflow_approvals',
'/host_metrics',
'/templates',
'/credentials',
'/projects',

View File

@@ -0,0 +1,156 @@
import React, { useCallback, useEffect, useState } from 'react';
import { t } from '@lingui/macro';
import ScreenHeader from 'components/ScreenHeader/ScreenHeader';
import { HostMetricsAPI } from 'api';
import useRequest from 'hooks/useRequest';
import PaginatedTable, {
HeaderRow,
HeaderCell,
} from 'components/PaginatedTable';
import DataListToolbar from 'components/DataListToolbar';
import { getQSConfig, parseQueryString } from 'util/qs';
import { Card, PageSection } from '@patternfly/react-core';
import { useLocation } from 'react-router-dom';
import useSelected from 'hooks/useSelected';
import HostMetricsListItem from './HostMetricsListItem';
import HostMetricsDeleteButton from './HostMetricsDeleteButton';
const QS_CONFIG = getQSConfig('host_metrics', {
page: 1,
page_size: 20,
order_by: 'hostname',
deleted: false,
});
function HostMetrics() {
const location = useLocation();
const [breadcrumbConfig] = useState({
'/host_metrics': t`Host Metrics`,
});
const {
result: { count, results },
isLoading,
error,
request: readHostMetrics,
} = useRequest(
useCallback(async () => {
const params = parseQueryString(QS_CONFIG, location.search);
const list = await HostMetricsAPI.read(params);
return {
count: list.data.count,
results: list.data.results,
};
}, [location]),
{ results: [], count: 0 }
);
useEffect(() => {
readHostMetrics();
}, [readHostMetrics]);
const { selected, isAllSelected, handleSelect, selectAll, clearSelected } =
useSelected(results);
return (
<>
<ScreenHeader streamType="none" breadcrumbConfig={breadcrumbConfig} />
<PageSection>
<Card>
<PaginatedTable
contentError={error}
hasContentLoading={isLoading}
items={results}
itemCount={count}
pluralizedItemName={t`Host Metrics`}
renderRow={(item, index) => (
<HostMetricsListItem
key={item.id}
item={item}
isSelected={selected.some(
(row) => row.hostname === item.hostname
)}
onSelect={() => handleSelect(item)}
rowIndex={index}
/>
)}
qsConfig={QS_CONFIG}
toolbarSearchColumns={[
{
name: t`Hostname`,
key: 'hostname__icontains',
isDefault: true,
},
]}
toolbarSearchableKeys={[]}
toolbarRelatedSearchableKeys={[]}
renderToolbar={(props) => (
<DataListToolbar
{...props}
advancedSearchDisabled
fillWidth
isAllSelected={isAllSelected}
onSelectAll={selectAll}
additionalControls={[
<HostMetricsDeleteButton
key="delete"
onDelete={() =>
Promise.all(
selected.map((hostMetric) =>
HostMetricsAPI.destroy(hostMetric.id)
)
).then(() => {
readHostMetrics();
clearSelected();
})
}
itemsToDelete={selected}
pluralizedItemName={t`Host Metrics`}
/>,
]}
/>
)}
headerRow={
<HeaderRow qsConfig={QS_CONFIG}>
<HeaderCell sortKey="hostname">{t`Hostname`}</HeaderCell>
<HeaderCell
sortKey="first_automation"
tooltip={t`When was the host first automated`}
>
{t`First automated`}
</HeaderCell>
<HeaderCell
sortKey="last_automation"
tooltip={t`When was the host last automated`}
>
{t`Last automated`}
</HeaderCell>
<HeaderCell
sortKey="automated_counter"
tooltip={t`How many times was the host automated`}
>
{t`Automation`}
</HeaderCell>
<HeaderCell
sortKey="used_in_inventories"
tooltip={t`How many inventories is the host in, recomputed on a weekly schedule`}
>
{t`Inventories`}
</HeaderCell>
<HeaderCell
sortKey="deleted_counter"
tooltip={t`How many times was the host deleted`}
>
{t`Deleted`}
</HeaderCell>
</HeaderRow>
}
/>
</Card>
</PageSection>
</>
);
}
export { HostMetrics as _HostMetrics };
export default HostMetrics;

View File

@@ -0,0 +1,69 @@
import React from 'react';
import { act } from 'react-dom/test-utils';
import { HostMetricsAPI } from 'api';
import {
mountWithContexts,
waitForElement,
} from '../../../testUtils/enzymeHelpers';
import HostMetrics from './HostMetrics';
jest.mock('../../api');
const mockHostMetrics = [
{
hostname: 'Host name',
first_automation: 'now',
last_automation: 'now',
automated_counter: 1,
used_in_inventories: 1,
deleted_counter: 1,
id: 1,
url: '',
},
];
function waitForLoaded(wrapper) {
return waitForElement(
wrapper,
'HostList',
(el) => el.find('ContentLoading').length === 0
);
}
describe('<HostMetrics />', () => {
beforeEach(() => {
HostMetricsAPI.read.mockResolvedValue({
data: {
count: mockHostMetrics.length,
results: mockHostMetrics,
},
});
});
afterEach(() => {
jest.clearAllMocks();
});
test('initially renders successfully', async () => {
await act(async () => {
mountWithContexts(
<HostMetrics
match={{ path: '/hosts', url: '/hosts' }}
location={{ search: '', pathname: '/hosts' }}
/>
);
});
});
test('HostMetrics are retrieved from the api and the components finishes loading', async () => {
let wrapper;
await act(async () => {
wrapper = mountWithContexts(<HostMetrics />);
});
await waitForLoaded(wrapper);
expect(HostMetricsAPI.read).toHaveBeenCalled();
expect(wrapper.find('HostMetricsListItem')).toHaveLength(1);
});
});

View File

@@ -0,0 +1,205 @@
import React, { useState } from 'react';
import { func, node, string, arrayOf, shape } from 'prop-types';
import styled from 'styled-components';
import { Alert, Badge, Button, Tooltip } from '@patternfly/react-core';
import { t } from '@lingui/macro';
import { getRelatedResourceDeleteCounts } from 'util/getRelatedResourceDeleteDetails';
import AlertModal from '../../components/AlertModal';
import ErrorDetail from '../../components/ErrorDetail';
const WarningMessage = styled(Alert)`
margin-top: 10px;
`;
const Label = styled.span`
&& {
margin-right: 10px;
}
`;
const ItemToDelete = shape({
hostname: string.isRequired,
});
function HostMetricsDeleteButton({
itemsToDelete,
pluralizedItemName,
onDelete,
deleteDetailsRequests,
warningMessage,
deleteMessage,
}) {
const [isModalOpen, setIsModalOpen] = useState(false);
const [deleteDetails, setDeleteDetails] = useState(null);
const [isLoading, setIsLoading] = useState(false);
const [deleteMessageError, setDeleteMessageError] = useState();
const handleDelete = () => {
onDelete();
toggleModal();
};
const toggleModal = async (isOpen) => {
setIsLoading(true);
setDeleteDetails(null);
if (
isOpen &&
itemsToDelete.length === 1 &&
deleteDetailsRequests?.length > 0
) {
const { results, error } = await getRelatedResourceDeleteCounts(
deleteDetailsRequests
);
if (error) {
setDeleteMessageError(error);
} else {
setDeleteDetails(results);
}
}
setIsLoading(false);
setIsModalOpen(isOpen);
};
const renderTooltip = () => {
if (itemsToDelete.length) {
return t`Soft delete`;
}
return t`Select a row to delete`;
};
const modalTitle = t`Soft delete ${pluralizedItemName}?`;
const isDisabled = itemsToDelete.length === 0;
const buildDeleteWarning = () => {
const deleteMessages = [];
if (warningMessage) {
deleteMessages.push(warningMessage);
}
if (deleteMessage) {
if (itemsToDelete.length > 1 || deleteDetails) {
deleteMessages.push(deleteMessage);
}
}
return (
<div>
{deleteMessages.map((message) => (
<div aria-label={message} key={message}>
{message}
</div>
))}
{deleteDetails &&
Object.entries(deleteDetails).map(([key, value]) => (
<div key={key} aria-label={`${key}: ${value}`}>
<Label>{key}</Label>
<Badge>{value}</Badge>
</div>
))}
</div>
);
};
if (deleteMessageError) {
return (
<AlertModal
isOpen={deleteMessageError}
title={t`Error!`}
onClose={() => {
toggleModal(false);
setDeleteMessageError();
}}
>
<ErrorDetail error={deleteMessageError} />
</AlertModal>
);
}
const shouldShowDeleteWarning =
warningMessage ||
(itemsToDelete.length === 1 && deleteDetails) ||
(itemsToDelete.length > 1 && deleteMessage);
return (
<>
<Tooltip content={renderTooltip()} position="top">
<div>
<Button
variant="secondary"
isLoading={isLoading}
ouiaId="delete-button"
spinnerAriaValueText={isLoading ? 'Loading' : undefined}
aria-label={t`Delete`}
onClick={() => toggleModal(true)}
isDisabled={isDisabled}
>
{t`Delete`}
</Button>
</div>
</Tooltip>
{isModalOpen && (
<AlertModal
variant="danger"
title={modalTitle}
isOpen={isModalOpen}
onClose={() => toggleModal(false)}
actions={[
<Button
ouiaId="delete-modal-confirm"
key="delete"
variant="danger"
aria-label={t`confirm delete`}
isDisabled={Boolean(
deleteDetails && itemsToDelete[0]?.type === 'credential_type'
)}
onClick={handleDelete}
>
{t`Delete`}
</Button>,
<Button
ouiaId="delete-cancel"
key="cancel"
variant="link"
aria-label={t`cancel delete`}
onClick={() => toggleModal(false)}
>
{t`Cancel`}
</Button>,
]}
>
<div>{t`This action will soft delete the following:`}</div>
{itemsToDelete.map((item) => (
<span
key={item.hostname}
id={`item-to-be-deleted-${item.hostname}`}
>
<strong>{item.hostname}</strong>
<br />
</span>
))}
{shouldShowDeleteWarning && (
<WarningMessage
variant="warning"
isInline
title={buildDeleteWarning()}
/>
)}
</AlertModal>
)}
</>
);
}
HostMetricsDeleteButton.propTypes = {
onDelete: func.isRequired,
itemsToDelete: arrayOf(ItemToDelete).isRequired,
pluralizedItemName: string,
warningMessage: node,
};
HostMetricsDeleteButton.defaultProps = {
pluralizedItemName: 'Items',
warningMessage: null,
};
export default HostMetricsDeleteButton;

View File

@@ -0,0 +1,36 @@
import 'styled-components/macro';
import React from 'react';
import { Tr, Td } from '@patternfly/react-table';
import { formatDateString } from 'util/dates';
import { HostMetrics } from 'types';
import { t } from '@lingui/macro';
import { bool, func } from 'prop-types';
function HostMetricsListItem({ item, isSelected, onSelect, rowIndex }) {
return (
<Tr
id={`host_metrics-row-${item.hostname}`}
ouiaId={`host-metrics-row-${item.hostname}`}
>
<Td select={{ rowIndex, isSelected, onSelect }} dataLabel={t`Selected`} />
<Td dataLabel={t`Hostname`}>{item.hostname}</Td>
<Td dataLabel={t`First automation`}>
{formatDateString(item.first_automation)}
</Td>
<Td dataLabel={t`Last automation`}>
{formatDateString(item.last_automation)}
</Td>
<Td dataLabel={t`Automation`}>{item.automated_counter}</Td>
<Td dataLabel={t`Inventories`}>{item.used_in_inventories || 0}</Td>
<Td dataLabel={t`Deleted`}>{item.deleted_counter}</Td>
</Tr>
);
}
HostMetricsListItem.propTypes = {
item: HostMetrics.isRequired,
isSelected: bool.isRequired,
onSelect: func.isRequired,
};
export default HostMetricsListItem;

View File

@@ -0,0 +1 @@
export { default } from './HostMetrics';

View File

@@ -439,3 +439,12 @@ export const Toast = shape({
hasTimeout: bool,
message: string,
});
export const HostMetrics = shape({
hostname: string.isRequired,
first_automation: string.isRequired,
last_automation: string.isRequired,
automated_counter: number.isRequired,
used_in_inventories: number,
deleted_counter: number,
});