mirror of
https://github.com/ansible/awx.git
synced 2026-01-11 10:00:01 -03:30
add activity stream ui
This commit is contained in:
parent
87a2039ded
commit
7c57a8e5d0
@ -1,3 +1,4 @@
|
||||
import ActivityStream from './models/ActivityStream';
|
||||
import AdHocCommands from './models/AdHocCommands';
|
||||
import Applications from './models/Applications';
|
||||
import Auth from './models/Auth';
|
||||
@ -39,6 +40,7 @@ import WorkflowJobTemplateNodes from './models/WorkflowJobTemplateNodes';
|
||||
import WorkflowJobTemplates from './models/WorkflowJobTemplates';
|
||||
import WorkflowJobs from './models/WorkflowJobs';
|
||||
|
||||
const ActivityStreamAPI = new ActivityStream();
|
||||
const AdHocCommandsAPI = new AdHocCommands();
|
||||
const ApplicationsAPI = new Applications();
|
||||
const AuthAPI = new Auth();
|
||||
@ -81,6 +83,7 @@ const WorkflowJobTemplatesAPI = new WorkflowJobTemplates();
|
||||
const WorkflowJobsAPI = new WorkflowJobs();
|
||||
|
||||
export {
|
||||
ActivityStreamAPI,
|
||||
AdHocCommandsAPI,
|
||||
ApplicationsAPI,
|
||||
AuthAPI,
|
||||
|
||||
10
awx/ui_next/src/api/models/ActivityStream.js
Normal file
10
awx/ui_next/src/api/models/ActivityStream.js
Normal file
@ -0,0 +1,10 @@
|
||||
import Base from '../Base';
|
||||
|
||||
class ActivityStream extends Base {
|
||||
constructor(http) {
|
||||
super(http);
|
||||
this.baseUrl = '/api/v2/activity_stream/';
|
||||
}
|
||||
}
|
||||
|
||||
export default ActivityStream;
|
||||
@ -1,5 +1,6 @@
|
||||
import { t } from '@lingui/macro';
|
||||
|
||||
import ActivityStream from './screens/ActivityStream';
|
||||
import Applications from './screens/Application';
|
||||
import Credentials from './screens/Credential';
|
||||
import CredentialTypes from './screens/CredentialType';
|
||||
@ -44,6 +45,11 @@ function getRouteConfig(i18n) {
|
||||
path: '/schedules',
|
||||
screen: Schedules,
|
||||
},
|
||||
{
|
||||
title: i18n._(t`Activity Stream`),
|
||||
path: '/activity_stream',
|
||||
screen: ActivityStream,
|
||||
},
|
||||
{
|
||||
title: i18n._(t`Workflow Approvals`),
|
||||
path: '/workflow_approvals',
|
||||
|
||||
228
awx/ui_next/src/screens/ActivityStream/ActivityStream.jsx
Normal file
228
awx/ui_next/src/screens/ActivityStream/ActivityStream.jsx
Normal file
@ -0,0 +1,228 @@
|
||||
import React, { Fragment, useState, useEffect, useCallback } from 'react';
|
||||
import { useLocation, useHistory } from 'react-router-dom';
|
||||
import { withI18n } from '@lingui/react';
|
||||
import { t } from '@lingui/macro';
|
||||
import {
|
||||
Card,
|
||||
PageSection,
|
||||
PageSectionVariants,
|
||||
Select,
|
||||
SelectVariant,
|
||||
SelectOption,
|
||||
Title,
|
||||
} from '@patternfly/react-core';
|
||||
|
||||
import DatalistToolbar from '../../components/DataListToolbar';
|
||||
import PaginatedTable, {
|
||||
HeaderRow,
|
||||
HeaderCell,
|
||||
} from '../../components/PaginatedTable';
|
||||
import useRequest from '../../util/useRequest';
|
||||
import { getQSConfig, parseQueryString } from '../../util/qs';
|
||||
import { ActivityStreamAPI } from '../../api';
|
||||
|
||||
import ActivityStreamListItem from './ActivityStreamListItem';
|
||||
|
||||
function ActivityStream({ i18n }) {
|
||||
const { light } = PageSectionVariants;
|
||||
|
||||
const [isTypeDropdownOpen, setIsTypeDropdownOpen] = useState(false);
|
||||
const location = useLocation();
|
||||
const history = useHistory();
|
||||
const urlParams = new URLSearchParams(location.search);
|
||||
|
||||
const activityStreamType = urlParams.get('type');
|
||||
|
||||
let typeParams = {};
|
||||
|
||||
if (activityStreamType !== 'all') {
|
||||
typeParams = {
|
||||
or__object1__in: activityStreamType,
|
||||
or__object2__in: activityStreamType,
|
||||
};
|
||||
}
|
||||
|
||||
const QS_CONFIG = getQSConfig(
|
||||
'activity_stream',
|
||||
{
|
||||
page: 1,
|
||||
page_size: 20,
|
||||
order_by: '-timestamp',
|
||||
},
|
||||
['id', 'page', 'page_size']
|
||||
);
|
||||
|
||||
const {
|
||||
result: { results, count, relatedSearchableKeys, searchableKeys },
|
||||
error: contentError,
|
||||
isLoading,
|
||||
request: fetchActivityStream,
|
||||
} = useRequest(
|
||||
useCallback(
|
||||
async () => {
|
||||
const params = parseQueryString(QS_CONFIG, location.search);
|
||||
const [response, actionsResponse] = await Promise.all([
|
||||
ActivityStreamAPI.read({ ...params, ...typeParams }),
|
||||
ActivityStreamAPI.readOptions(),
|
||||
]);
|
||||
return {
|
||||
results: response.data.results,
|
||||
count: response.data.count,
|
||||
relatedSearchableKeys: (
|
||||
actionsResponse?.data?.related_search_fields || []
|
||||
).map(val => val.slice(0, -8)),
|
||||
searchableKeys: Object.keys(
|
||||
actionsResponse.data.actions?.GET || {}
|
||||
).filter(key => actionsResponse.data.actions?.GET[key].filterable),
|
||||
};
|
||||
},
|
||||
[location] // eslint-disable-line react-hooks/exhaustive-deps
|
||||
),
|
||||
{
|
||||
results: [],
|
||||
count: 0,
|
||||
relatedSearchableKeys: [],
|
||||
searchableKeys: [],
|
||||
}
|
||||
);
|
||||
useEffect(() => {
|
||||
fetchActivityStream();
|
||||
}, [fetchActivityStream]);
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<PageSection
|
||||
variant={light}
|
||||
className="pf-m-condensed"
|
||||
style={{ display: 'flex', justifyContent: 'space-between' }}
|
||||
>
|
||||
<Title size="2xl" headingLevel="h2">
|
||||
{i18n._(t`Activity Stream`)}
|
||||
</Title>
|
||||
<Select
|
||||
width="250px"
|
||||
variant={SelectVariant.single}
|
||||
aria-label={i18n._(t`Activity Stream type selector`)}
|
||||
className="activityTypeSelect"
|
||||
onToggle={setIsTypeDropdownOpen}
|
||||
onSelect={(event, selection) => {
|
||||
if (selection) {
|
||||
urlParams.set('type', selection);
|
||||
}
|
||||
setIsTypeDropdownOpen(false);
|
||||
history.push(`${location.pathname}?${urlParams.toString()}`);
|
||||
}}
|
||||
selections={activityStreamType}
|
||||
isOpen={isTypeDropdownOpen}
|
||||
>
|
||||
<SelectOption key="all_activity" value="all">
|
||||
{i18n._(t`All activity`)}
|
||||
</SelectOption>
|
||||
<SelectOption key="inventories" value="inventory">
|
||||
{i18n._(t`Inventories`)}
|
||||
</SelectOption>
|
||||
<SelectOption
|
||||
key="applications"
|
||||
value="o_auth2_application,o_auth2_access_token"
|
||||
>
|
||||
{i18n._(t`Applications & Tokens`)}
|
||||
</SelectOption>
|
||||
<SelectOption key="credentials" value="credential">
|
||||
{i18n._(t`Credentials`)}
|
||||
</SelectOption>
|
||||
<SelectOption key="hosts" value="host">
|
||||
{i18n._(t`Hosts`)}
|
||||
</SelectOption>
|
||||
<SelectOption key="inventory_scripts" value="custom_inventory_script">
|
||||
{i18n._(t`Inventory Scripts`)}
|
||||
</SelectOption>
|
||||
<SelectOption key="jobs" value="job">
|
||||
{i18n._(t`Jobs`)}
|
||||
</SelectOption>
|
||||
<SelectOption
|
||||
key="notification_templates"
|
||||
value="notification_template"
|
||||
>
|
||||
{i18n._(t`Notification Templates`)}
|
||||
</SelectOption>
|
||||
<SelectOption key="organizations" value="organization">
|
||||
{i18n._(t`Organizations`)}
|
||||
</SelectOption>
|
||||
<SelectOption key="projects" value="project">
|
||||
{i18n._(t`Projects`)}
|
||||
</SelectOption>
|
||||
<SelectOption key="credential_types" value="credential_type">
|
||||
{i18n._(t`Credential Types`)}
|
||||
</SelectOption>
|
||||
<SelectOption key="schedules" value="schedule">
|
||||
{i18n._(t`Schedules`)}
|
||||
</SelectOption>
|
||||
<SelectOption key="teams" value="team">
|
||||
{i18n._(t`Teams`)}
|
||||
</SelectOption>
|
||||
<SelectOption
|
||||
key="templates"
|
||||
value="job_template,workflow_job_template"
|
||||
>
|
||||
{i18n._(t`Templates`)}
|
||||
</SelectOption>
|
||||
<SelectOption key="users" value="user">
|
||||
{i18n._(t`Users`)}
|
||||
</SelectOption>
|
||||
<SelectOption key="workflow_approvals" value="workflow_approval">
|
||||
{i18n._(t`Workflow Approvals`)}
|
||||
</SelectOption>
|
||||
<SelectOption key="instance_groups" value="instance_group">
|
||||
{i18n._(t`Instance Groups`)}
|
||||
</SelectOption>
|
||||
<SelectOption key="settings" value="setting">
|
||||
{i18n._(t`Settings`)}
|
||||
</SelectOption>
|
||||
</Select>
|
||||
</PageSection>
|
||||
<PageSection>
|
||||
<Card>
|
||||
<PaginatedTable
|
||||
contentError={contentError}
|
||||
hasContentLoading={isLoading}
|
||||
items={results}
|
||||
itemCount={count}
|
||||
pluralizedItemName={i18n._(t`Events`)}
|
||||
qsConfig={QS_CONFIG}
|
||||
toolbarSearchColumns={[
|
||||
{
|
||||
name: i18n._(t`Keyword`),
|
||||
key: 'search',
|
||||
isDefault: true,
|
||||
},
|
||||
]}
|
||||
toolbarSortColumns={[
|
||||
{
|
||||
name: i18n._(t`Time`),
|
||||
key: 'timestamp',
|
||||
},
|
||||
]}
|
||||
toolbarSearchableKeys={searchableKeys}
|
||||
toolbarRelatedSearchableKeys={relatedSearchableKeys}
|
||||
headerRow={
|
||||
<HeaderRow qsConfig={QS_CONFIG}>
|
||||
<HeaderCell sortKey="timestamp">{i18n._(t`Time`)}</HeaderCell>
|
||||
<HeaderCell>{i18n._(t`Initiated by`)}</HeaderCell>
|
||||
<HeaderCell>{i18n._(t`Event`)}</HeaderCell>
|
||||
<HeaderCell>{i18n._(t`Actions`)}</HeaderCell>
|
||||
</HeaderRow>
|
||||
}
|
||||
renderToolbar={props => (
|
||||
<DatalistToolbar {...props} qsConfig={QS_CONFIG} />
|
||||
)}
|
||||
renderRow={streamItem => (
|
||||
<ActivityStreamListItem streamItem={streamItem} />
|
||||
)}
|
||||
/>
|
||||
</Card>
|
||||
</PageSection>
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
export default withI18n()(ActivityStream);
|
||||
@ -0,0 +1,25 @@
|
||||
import React from 'react';
|
||||
|
||||
import { mountWithContexts } from '../../../testUtils/enzymeHelpers';
|
||||
|
||||
import ActivityStream from './ActivityStream';
|
||||
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
}));
|
||||
|
||||
describe('<ActivityStream />', () => {
|
||||
let pageWrapper;
|
||||
|
||||
beforeEach(() => {
|
||||
pageWrapper = mountWithContexts(<ActivityStream />);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
pageWrapper.unmount();
|
||||
});
|
||||
|
||||
test('initially renders without crashing', () => {
|
||||
expect(pageWrapper.length).toBe(1);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,61 @@
|
||||
import React from 'react';
|
||||
import { shape } from 'prop-types';
|
||||
import { withI18n } from '@lingui/react';
|
||||
import { Tr, Td } from '@patternfly/react-table';
|
||||
import { t } from '@lingui/macro';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { formatDateString } from '../../util/dates';
|
||||
import { ActionsTd, ActionItem } from '../../components/PaginatedTable';
|
||||
|
||||
import StreamDetailButton from './StreamDetailButton';
|
||||
import buildDescription from './buildActivityDescription';
|
||||
|
||||
function ActivityStreamListItem({ streamItem, i18n }) {
|
||||
ActivityStreamListItem.propTypes = {
|
||||
streamItem: shape({}).isRequired,
|
||||
};
|
||||
|
||||
const buildUser = item => {
|
||||
let link;
|
||||
if (item?.summary_fields?.actor?.id) {
|
||||
link = (
|
||||
<Link to={`/users/${item.summary_fields.actor.id}/details`}>
|
||||
{item.summary_fields.actor.username}
|
||||
</Link>
|
||||
);
|
||||
} else if (item?.summary_fields?.actor) {
|
||||
link = i18n._(t`${item.summary_fields.actor.username} (deleted)`);
|
||||
} else {
|
||||
link = i18n._(t`system`);
|
||||
}
|
||||
return link;
|
||||
};
|
||||
|
||||
const labelId = `check-action-${streamItem.id}`;
|
||||
const user = buildUser(streamItem);
|
||||
const description = buildDescription(streamItem, i18n);
|
||||
|
||||
return (
|
||||
<Tr id={streamItem.id} aria-labelledby={labelId}>
|
||||
<Td />
|
||||
<Td dataLabel={i18n._(t`Time`)}>
|
||||
{streamItem.timestamp ? formatDateString(streamItem.timestamp) : ''}
|
||||
</Td>
|
||||
<Td dataLabel={i18n._(t`Initiated By`)}>{user}</Td>
|
||||
<Td id={labelId} dataLabel={i18n._(t`Event`)}>
|
||||
{description}
|
||||
</Td>
|
||||
<ActionsTd dataLabel={i18n._(t`Actions`)}>
|
||||
<ActionItem visible tooltip={i18n._(t`View event details`)}>
|
||||
<StreamDetailButton
|
||||
streamItem={streamItem}
|
||||
user={user}
|
||||
description={description}
|
||||
/>
|
||||
</ActionItem>
|
||||
</ActionsTd>
|
||||
</Tr>
|
||||
);
|
||||
}
|
||||
export default withI18n()(ActivityStreamListItem);
|
||||
@ -0,0 +1,22 @@
|
||||
import React from 'react';
|
||||
import { mountWithContexts } from '../../../testUtils/enzymeHelpers';
|
||||
import ActivityStreamListItem from './ActivityStreamListItem';
|
||||
|
||||
jest.mock('../../api/models/ActivityStream');
|
||||
|
||||
describe('<ActivityStreamListItem />', () => {
|
||||
test('initially renders succesfully', () => {
|
||||
mountWithContexts(
|
||||
<table>
|
||||
<tbody>
|
||||
<ActivityStreamListItem
|
||||
streamItem={{
|
||||
timestamp: '12:00:00',
|
||||
}}
|
||||
onSelect={() => {}}
|
||||
/>
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,64 @@
|
||||
import React, { useState } from 'react';
|
||||
import { withI18n } from '@lingui/react';
|
||||
import { t } from '@lingui/macro';
|
||||
import { Button, Modal } from '@patternfly/react-core';
|
||||
import { SearchPlusIcon } from '@patternfly/react-icons';
|
||||
|
||||
import { formatDateString } from '../../util/dates';
|
||||
|
||||
import { DetailList, Detail } from '../../components/DetailList';
|
||||
import { VariablesDetail } from '../../components/CodeMirrorInput';
|
||||
|
||||
function StreamDetailButton({ i18n, streamItem, user, description }) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const setting = streamItem?.summary_fields?.setting;
|
||||
const changeRows = Math.max(
|
||||
Object.keys(streamItem?.changes || []).length + 2,
|
||||
6
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
aria-label={i18n._(t`View event details`)}
|
||||
variant="plain"
|
||||
component="button"
|
||||
onClick={() => setIsOpen(true)}
|
||||
>
|
||||
<SearchPlusIcon />
|
||||
</Button>
|
||||
<Modal
|
||||
variant="large"
|
||||
isOpen={isOpen}
|
||||
title={i18n._(t`Event detail`)}
|
||||
aria-label={i18n._(t`Event detail modal`)}
|
||||
onClose={() => setIsOpen(false)}
|
||||
>
|
||||
<DetailList gutter="sm">
|
||||
<Detail
|
||||
label={i18n._(t`Time`)}
|
||||
value={formatDateString(streamItem.timestamp)}
|
||||
/>
|
||||
<Detail label={i18n._(t`Initiated by`)} value={user} />
|
||||
<Detail
|
||||
label={i18n._(t`Setting category`)}
|
||||
value={setting && setting[0]?.category}
|
||||
/>
|
||||
<Detail
|
||||
label={i18n._(t`Setting name`)}
|
||||
value={setting && setting[0]?.name}
|
||||
/>
|
||||
<Detail fullWidth label={i18n._(t`Action`)} value={description} />
|
||||
<VariablesDetail
|
||||
label={i18n._(t`Changes`)}
|
||||
rows={changeRows}
|
||||
value={streamItem?.changes}
|
||||
/>
|
||||
</DetailList>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default withI18n()(StreamDetailButton);
|
||||
@ -0,0 +1,21 @@
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { mountWithContexts } from '../../../testUtils/enzymeHelpers';
|
||||
import StreamDetailButton from './StreamDetailButton';
|
||||
|
||||
jest.mock('../../api/models/ActivityStream');
|
||||
|
||||
describe('<StreamDetailButton />', () => {
|
||||
test('initially renders succesfully', () => {
|
||||
mountWithContexts(
|
||||
<StreamDetailButton
|
||||
streamItem={{
|
||||
timestamp: '12:00:00',
|
||||
}}
|
||||
user={<Link to="/users/1/details">Bob</Link>}
|
||||
description={<span>foo</span>}
|
||||
/>
|
||||
);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,578 @@
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { t } from '@lingui/macro';
|
||||
|
||||
const buildAnchor = (obj, resource, activity) => {
|
||||
let url;
|
||||
let name;
|
||||
// try/except pattern asserts that:
|
||||
// if we encounter a case where a UI url can't or
|
||||
// shouldn't be generated, just supply the name of the resource
|
||||
try {
|
||||
// catch-all case to avoid generating urls if a resource has been deleted
|
||||
// if a resource still exists, it'll be serialized in the activity's summary_fields
|
||||
if (!activity.summary_fields[resource]) {
|
||||
throw new Error('The referenced resource no longer exists');
|
||||
}
|
||||
switch (resource) {
|
||||
case 'custom_inventory_script':
|
||||
url = `/inventory_scripts/${obj.id}/`;
|
||||
break;
|
||||
case 'group':
|
||||
if (
|
||||
activity.operation === 'create' ||
|
||||
activity.operation === 'delete'
|
||||
) {
|
||||
// the API formats the changes.inventory field as str 'myInventoryName-PrimaryKey'
|
||||
const [inventory_id] = activity.changes.inventory
|
||||
.split('-')
|
||||
.slice(-1);
|
||||
url = `/inventories/inventory/${inventory_id}/groups/${activity.changes.id}/details/`;
|
||||
} else {
|
||||
url = `/inventories/inventory/${
|
||||
activity.summary_fields.inventory[0].id
|
||||
}/groups/${activity.changes.id ||
|
||||
activity.changes.object1_pk}/details/`;
|
||||
}
|
||||
break;
|
||||
case 'host':
|
||||
url = `/hosts/${obj.id}/`;
|
||||
break;
|
||||
case 'job':
|
||||
url = `/jobs/${obj.id}/`;
|
||||
break;
|
||||
case 'inventory':
|
||||
url =
|
||||
obj?.kind === 'smart'
|
||||
? `/inventories/smart_inventory/${obj.id}/`
|
||||
: `/inventories/inventory/${obj.id}/`;
|
||||
break;
|
||||
case 'schedule':
|
||||
// schedule urls depend on the resource they're associated with
|
||||
if (activity.summary_fields.job_template) {
|
||||
const jt_id = activity.summary_fields.job_template[0].id;
|
||||
url = `/templates/job_template/${jt_id}/schedules/${obj.id}/`;
|
||||
} else if (activity.summary_fields.project) {
|
||||
url = `/projects/${activity.summary_fields.project[0].id}/schedules/${obj.id}/`;
|
||||
} else if (activity.summary_fields.system_job_template) {
|
||||
url = null;
|
||||
} else {
|
||||
// urls for inventory sync schedules currently depend on having
|
||||
// an inventory id and group id
|
||||
throw new Error(
|
||||
'activity.summary_fields to build this url not implemented yet'
|
||||
);
|
||||
}
|
||||
break;
|
||||
case 'setting':
|
||||
url = `/settings/`;
|
||||
break;
|
||||
case 'notification_template':
|
||||
url = `/notification_templates/${obj.id}/`;
|
||||
break;
|
||||
case 'role':
|
||||
throw new Error(
|
||||
'role object management is not consolidated to a single UI view'
|
||||
);
|
||||
case 'job_template':
|
||||
url = `/templates/job_template/${obj.id}/`;
|
||||
break;
|
||||
case 'workflow_job_template':
|
||||
url = `/templates/workflow_job_template/${obj.id}/`;
|
||||
break;
|
||||
case 'workflow_job_template_node': {
|
||||
const {
|
||||
wfjt_id,
|
||||
wfjt_name,
|
||||
} = activity.summary_fields.workflow_job_template[0];
|
||||
url = `/templates/workflow_job_template/${wfjt_id}/`;
|
||||
name = wfjt_name;
|
||||
break;
|
||||
}
|
||||
case 'workflow_job':
|
||||
url = `/workflows/${obj.id}/`;
|
||||
break;
|
||||
case 'label':
|
||||
url = null;
|
||||
break;
|
||||
case 'inventory_source': {
|
||||
const inventoryId = (obj.inventory || '').split('-').reverse()[0];
|
||||
url = `/inventories/inventory/${inventoryId}/sources/${obj.id}/details/`;
|
||||
break;
|
||||
}
|
||||
case 'o_auth2_application':
|
||||
url = `/applications/${obj.id}/`;
|
||||
break;
|
||||
case 'workflow_approval':
|
||||
url = `/jobs/workflow/${activity.summary_fields.workflow_job[0].id}/output/`;
|
||||
name = `${activity.summary_fields.workflow_job[0].name} | ${activity.summary_fields.workflow_approval[0].name}`;
|
||||
break;
|
||||
case 'workflow_approval_template':
|
||||
url = `/templates/workflow_job_template/${activity.summary_fields.workflow_job_template[0].id}/visualizer/`;
|
||||
name = `${activity.summary_fields.workflow_job_template[0].name} | ${activity.summary_fields.workflow_approval_template[0].name}`;
|
||||
break;
|
||||
default:
|
||||
url = `/${resource}s/${obj.id}/`;
|
||||
}
|
||||
|
||||
name = name || obj.name || obj.username;
|
||||
|
||||
if (url) {
|
||||
return <Link to={url}>{name}</Link>;
|
||||
}
|
||||
|
||||
return <span>{name}</span>;
|
||||
} catch (err) {
|
||||
return <span>{obj.name || obj.username || ''}</span>;
|
||||
}
|
||||
};
|
||||
|
||||
const getPastTense = item => {
|
||||
return /e$/.test(item) ? `${item}d ` : `${item}ed `;
|
||||
};
|
||||
|
||||
const isGroupRelationship = item => {
|
||||
return (
|
||||
item.object1 === 'group' &&
|
||||
item.object2 === 'group' &&
|
||||
item.summary_fields.group.length > 1
|
||||
);
|
||||
};
|
||||
|
||||
const buildLabeledLink = (label, link) => {
|
||||
return (
|
||||
<span>
|
||||
{label} {link}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
export default (activity, i18n) => {
|
||||
const labeledLinks = [];
|
||||
// Activity stream objects will outlive the resources they reference
|
||||
// in that case, summary_fields will not be available - show generic error text instead
|
||||
try {
|
||||
switch (activity.object_association) {
|
||||
// explicit role dis+associations
|
||||
case 'role': {
|
||||
let { object1, object2 } = activity;
|
||||
|
||||
// if object1 winds up being the role's resource, we need to swap the objects
|
||||
// in order to make the sentence make sense.
|
||||
if (activity.object_type === object1) {
|
||||
object1 = activity.object2;
|
||||
object2 = activity.object1;
|
||||
}
|
||||
|
||||
// object1 field is resource targeted by the dis+association
|
||||
// object2 field is the resource the role is inherited from
|
||||
// summary_field.role[0] contains ref info about the role
|
||||
switch (activity.operation) {
|
||||
// expected outcome: "disassociated <object2> role_name from <object1>"
|
||||
case 'disassociate':
|
||||
if (isGroupRelationship(activity)) {
|
||||
labeledLinks.push(
|
||||
buildLabeledLink(
|
||||
getPastTense(activity.operation),
|
||||
buildAnchor(
|
||||
activity.summary_fields.group[1],
|
||||
object2,
|
||||
activity
|
||||
)
|
||||
)
|
||||
);
|
||||
labeledLinks.push(
|
||||
buildLabeledLink(
|
||||
`${activity.summary_fields.role[0].role_field} from`,
|
||||
buildAnchor(
|
||||
activity.summary_fields.group[0],
|
||||
object1,
|
||||
activity
|
||||
)
|
||||
)
|
||||
);
|
||||
} else {
|
||||
labeledLinks.push(
|
||||
buildLabeledLink(
|
||||
getPastTense(activity.operation),
|
||||
buildAnchor(
|
||||
activity.summary_fields[object2][0],
|
||||
object2,
|
||||
activity
|
||||
)
|
||||
)
|
||||
);
|
||||
labeledLinks.push(
|
||||
buildLabeledLink(
|
||||
`${activity.summary_fields.role[0].role_field} from`,
|
||||
buildAnchor(
|
||||
activity.summary_fields[object1][0],
|
||||
object1,
|
||||
activity
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
break;
|
||||
// expected outcome: "associated <object2> role_name to <object1>"
|
||||
case 'associate':
|
||||
if (isGroupRelationship(activity)) {
|
||||
labeledLinks.push(
|
||||
buildLabeledLink(
|
||||
getPastTense(activity.operation),
|
||||
buildAnchor(
|
||||
activity.summary_fields.group[1],
|
||||
object2,
|
||||
activity
|
||||
)
|
||||
)
|
||||
);
|
||||
labeledLinks.push(
|
||||
buildLabeledLink(
|
||||
`${activity.summary_fields.role[0].role_field} to `,
|
||||
buildAnchor(
|
||||
activity.summary_fields.group[0],
|
||||
object1,
|
||||
activity
|
||||
)
|
||||
)
|
||||
);
|
||||
} else {
|
||||
labeledLinks.push(
|
||||
buildLabeledLink(
|
||||
getPastTense(activity.operation),
|
||||
buildAnchor(
|
||||
activity.summary_fields[object2][0],
|
||||
object2,
|
||||
activity
|
||||
)
|
||||
)
|
||||
);
|
||||
labeledLinks.push(
|
||||
buildLabeledLink(
|
||||
`${activity.summary_fields.role[0].role_field} to `,
|
||||
buildAnchor(
|
||||
activity.summary_fields[object1][0],
|
||||
object1,
|
||||
activity
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
break;
|
||||
// inherited role dis+associations (logic identical to case 'role')
|
||||
}
|
||||
case 'parents':
|
||||
// object1 field is resource targeted by the dis+association
|
||||
// object2 field is the resource the role is inherited from
|
||||
// summary_field.role[0] contains ref info about the role
|
||||
switch (activity.operation) {
|
||||
// expected outcome: "disassociated <object2> role_name from <object1>"
|
||||
case 'disassociate':
|
||||
if (isGroupRelationship(activity)) {
|
||||
labeledLinks.push(
|
||||
buildLabeledLink(
|
||||
`${getPastTense(activity.operation)} ${activity.object2}`,
|
||||
buildAnchor(
|
||||
activity.summary_fields.group[1],
|
||||
activity.object2,
|
||||
activity
|
||||
)
|
||||
)
|
||||
);
|
||||
labeledLinks.push(
|
||||
buildLabeledLink(
|
||||
`from ${activity.object1}`,
|
||||
buildAnchor(
|
||||
activity.summary_fields.group[0],
|
||||
activity.object1,
|
||||
activity
|
||||
)
|
||||
)
|
||||
);
|
||||
} else {
|
||||
labeledLinks.push(
|
||||
buildLabeledLink(
|
||||
getPastTense(activity.operation),
|
||||
buildAnchor(
|
||||
activity.summary_fields[activity.object2][0],
|
||||
activity.object2,
|
||||
activity
|
||||
)
|
||||
)
|
||||
);
|
||||
labeledLinks.push(
|
||||
buildLabeledLink(
|
||||
`${activity.summary_fields.role[0].role_field} from`,
|
||||
buildAnchor(
|
||||
activity.summary_fields[activity.object1][0],
|
||||
activity.object1,
|
||||
activity
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
break;
|
||||
// expected outcome: "associated <object2> role_name to <object1>"
|
||||
case 'associate':
|
||||
if (isGroupRelationship(activity)) {
|
||||
labeledLinks.push(
|
||||
buildLabeledLink(
|
||||
`${getPastTense(activity.operation)} ${activity.object1}`,
|
||||
buildAnchor(
|
||||
activity.summary_fields.group[0],
|
||||
activity.object1,
|
||||
activity
|
||||
)
|
||||
)
|
||||
);
|
||||
labeledLinks.push(
|
||||
buildLabeledLink(
|
||||
`to ${activity.object2}`,
|
||||
buildAnchor(
|
||||
activity.summary_fields.group[1],
|
||||
activity.object2,
|
||||
activity
|
||||
)
|
||||
)
|
||||
);
|
||||
} else {
|
||||
labeledLinks.push(
|
||||
buildLabeledLink(
|
||||
getPastTense(activity.operation),
|
||||
buildAnchor(
|
||||
activity.summary_fields[activity.object2][0],
|
||||
activity.object2,
|
||||
activity
|
||||
)
|
||||
)
|
||||
);
|
||||
labeledLinks.push(
|
||||
buildLabeledLink(
|
||||
`${activity.summary_fields.role[0].role_field} to`,
|
||||
buildAnchor(
|
||||
activity.summary_fields[activity.object1][0],
|
||||
activity.object1,
|
||||
activity
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
break;
|
||||
// CRUD operations / resource on resource dis+associations
|
||||
default:
|
||||
switch (activity.operation) {
|
||||
// expected outcome: "disassociated <object2> from <object1>"
|
||||
case 'disassociate':
|
||||
if (isGroupRelationship(activity)) {
|
||||
labeledLinks.push(
|
||||
buildLabeledLink(
|
||||
`${getPastTense(activity.operation)} ${activity.object2}`,
|
||||
buildAnchor(
|
||||
activity.summary_fields.group[1],
|
||||
activity.object2,
|
||||
activity
|
||||
)
|
||||
)
|
||||
);
|
||||
labeledLinks.push(
|
||||
buildLabeledLink(
|
||||
`from ${activity.object1}`,
|
||||
buildAnchor(
|
||||
activity.summary_fields.group[0],
|
||||
activity.object1,
|
||||
activity
|
||||
)
|
||||
)
|
||||
);
|
||||
} else if (
|
||||
activity.object1 === 'workflow_job_template_node' &&
|
||||
activity.object2 === 'workflow_job_template_node'
|
||||
) {
|
||||
labeledLinks.push(
|
||||
buildLabeledLink(
|
||||
`${getPastTense(activity.operation)} two nodes on workflow`,
|
||||
buildAnchor(
|
||||
activity.summary_fields[activity.object1[0]],
|
||||
activity.object1,
|
||||
activity
|
||||
)
|
||||
)
|
||||
);
|
||||
} else {
|
||||
labeledLinks.push(
|
||||
buildLabeledLink(
|
||||
`${getPastTense(activity.operation)} ${activity.object2}`,
|
||||
buildAnchor(
|
||||
activity.summary_fields[activity.object2][0],
|
||||
activity.object2,
|
||||
activity
|
||||
)
|
||||
)
|
||||
);
|
||||
labeledLinks.push(
|
||||
buildLabeledLink(
|
||||
`from ${activity.object1}`,
|
||||
buildAnchor(
|
||||
activity.summary_fields[activity.object1][0],
|
||||
activity.object1,
|
||||
activity
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
break;
|
||||
// expected outcome "associated <object2> to <object1>"
|
||||
case 'associate':
|
||||
// groups are the only resource that can be associated/disassociated into each other
|
||||
if (isGroupRelationship(activity)) {
|
||||
labeledLinks.push(
|
||||
buildLabeledLink(
|
||||
`${getPastTense(activity.operation)} ${activity.object1}`,
|
||||
buildAnchor(
|
||||
activity.summary_fields.group[0],
|
||||
activity.object1,
|
||||
activity
|
||||
)
|
||||
)
|
||||
);
|
||||
labeledLinks.push(
|
||||
buildLabeledLink(
|
||||
`to ${activity.object2}`,
|
||||
buildAnchor(
|
||||
activity.summary_fields.group[1],
|
||||
activity.object2,
|
||||
activity
|
||||
)
|
||||
)
|
||||
);
|
||||
} else if (
|
||||
activity.object1 === 'workflow_job_template_node' &&
|
||||
activity.object2 === 'workflow_job_template_node'
|
||||
) {
|
||||
labeledLinks.push(
|
||||
buildLabeledLink(
|
||||
`${getPastTense(activity.operation)} two nodes on workflow`,
|
||||
buildAnchor(
|
||||
activity.summary_fields[activity.object1[0]],
|
||||
activity.object1,
|
||||
activity
|
||||
)
|
||||
)
|
||||
);
|
||||
} else {
|
||||
labeledLinks.push(
|
||||
buildLabeledLink(
|
||||
`${getPastTense(activity.operation)} ${activity.object1}`,
|
||||
buildAnchor(
|
||||
activity.summary_fields[activity.object1][0],
|
||||
activity.object1,
|
||||
activity
|
||||
)
|
||||
)
|
||||
);
|
||||
labeledLinks.push(
|
||||
buildLabeledLink(
|
||||
`to ${activity.object2}`,
|
||||
buildAnchor(
|
||||
activity.summary_fields[activity.object2][0],
|
||||
activity.object2,
|
||||
activity
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
break;
|
||||
case 'delete':
|
||||
labeledLinks.push(
|
||||
buildLabeledLink(
|
||||
`${getPastTense(activity.operation)} ${activity.object1}`,
|
||||
buildAnchor(activity.changes, activity.object1, activity)
|
||||
)
|
||||
);
|
||||
break;
|
||||
// expected outcome: "operation <object1>"
|
||||
case 'update':
|
||||
if (
|
||||
activity.object1 === 'workflow_approval' &&
|
||||
activity?.changes?.status?.length === 2
|
||||
) {
|
||||
let operationText = '';
|
||||
if (activity.changes.status[1] === 'successful') {
|
||||
operationText = i18n._(t`approved`);
|
||||
} else if (activity.changes.status[1] === 'failed') {
|
||||
if (
|
||||
activity.changes.timed_out &&
|
||||
activity.changes.timed_out[1] === true
|
||||
) {
|
||||
operationText = i18n._(t`timed out`);
|
||||
} else {
|
||||
operationText = i18n._(t`denied`);
|
||||
}
|
||||
} else {
|
||||
operationText = i18n._(t`updated`);
|
||||
}
|
||||
labeledLinks.push(
|
||||
buildLabeledLink(
|
||||
`${operationText} ${activity.object1}`,
|
||||
buildAnchor(
|
||||
activity.summary_fields[activity.object1][0],
|
||||
activity.object1,
|
||||
activity
|
||||
)
|
||||
)
|
||||
);
|
||||
} else {
|
||||
labeledLinks.push(
|
||||
buildLabeledLink(
|
||||
`${getPastTense(activity.operation)} ${activity.object1}`,
|
||||
buildAnchor(
|
||||
activity.summary_fields[activity.object1][0],
|
||||
activity.object1,
|
||||
activity
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
break;
|
||||
case 'create':
|
||||
labeledLinks.push(
|
||||
buildLabeledLink(
|
||||
`${getPastTense(activity.operation)} ${activity.object1}`,
|
||||
buildAnchor(activity.changes, activity.object1, activity)
|
||||
)
|
||||
);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
break;
|
||||
}
|
||||
} catch (err) {
|
||||
return <span>{i18n._(t`Event summary not available`)}</span>;
|
||||
}
|
||||
|
||||
return (
|
||||
<span>
|
||||
{labeledLinks.reduce(
|
||||
(acc, x) =>
|
||||
acc === null ? (
|
||||
x
|
||||
) : (
|
||||
<>
|
||||
{acc} {x}
|
||||
</>
|
||||
),
|
||||
null
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,10 @@
|
||||
import { mount } from 'enzyme';
|
||||
|
||||
import buildDescription from './buildActivityDescription';
|
||||
|
||||
describe('buildActivityStream', () => {
|
||||
test('initially renders succesfully', () => {
|
||||
const description = mount(buildDescription({}, {}));
|
||||
expect(description.find('span').length).toBe(1);
|
||||
});
|
||||
});
|
||||
1
awx/ui_next/src/screens/ActivityStream/index.js
Normal file
1
awx/ui_next/src/screens/ActivityStream/index.js
Normal file
@ -0,0 +1 @@
|
||||
export { default } from './ActivityStream';
|
||||
Loading…
x
Reference in New Issue
Block a user