mirror of
https://github.com/ansible/awx.git
synced 2026-05-20 07:17:40 -02:30
Merge pull request #4677 from marshmalien/4430-host-details-modal
[ui_next] Add host details modal Reviewed-by: https://github.com/apps/softwarefactory-project-zuul
This commit is contained in:
@@ -66,15 +66,15 @@ function HostEventService (
|
|||||||
obj.class = 'HostEvent-status--failed';
|
obj.class = 'HostEvent-status--failed';
|
||||||
obj.status = 'failed';
|
obj.status = 'failed';
|
||||||
}
|
}
|
||||||
// catch the changed case before ok, because both can be true
|
|
||||||
if (event.changed) {
|
|
||||||
obj.class = 'HostEvent-status--changed';
|
|
||||||
obj.status = 'changed';
|
|
||||||
}
|
|
||||||
if (event.event === 'runner_on_ok' || event.event === 'runner_on_async_ok') {
|
if (event.event === 'runner_on_ok' || event.event === 'runner_on_async_ok') {
|
||||||
obj.class = 'HostEvent-status--ok';
|
obj.class = 'HostEvent-status--ok';
|
||||||
obj.status = 'ok';
|
obj.status = 'ok';
|
||||||
}
|
}
|
||||||
|
// if both 'changed' and 'ok' are true, show 'changed' status
|
||||||
|
if (event.changed) {
|
||||||
|
obj.class = 'HostEvent-status--changed';
|
||||||
|
obj.status = 'changed';
|
||||||
|
}
|
||||||
if (event.event === 'runner_on_skipped') {
|
if (event.event === 'runner_on_skipped') {
|
||||||
obj.class = 'HostEvent-status--skipped';
|
obj.class = 'HostEvent-status--skipped';
|
||||||
obj.status = 'skipped';
|
obj.status = 'skipped';
|
||||||
|
|||||||
@@ -1,28 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { mount } from 'enzyme';
|
|
||||||
import JobStatusIcon from './JobStatusIcon';
|
|
||||||
|
|
||||||
describe('JobStatusIcon', () => {
|
|
||||||
test('renders the successful job', () => {
|
|
||||||
const wrapper = mount(<JobStatusIcon status="successful" />);
|
|
||||||
expect(wrapper).toHaveLength(1);
|
|
||||||
expect(wrapper.find('JobStatusIcon__SuccessfulTop')).toHaveLength(1);
|
|
||||||
expect(wrapper.find('JobStatusIcon__SuccessfulBottom')).toHaveLength(1);
|
|
||||||
});
|
|
||||||
test('renders running job', () => {
|
|
||||||
const wrapper = mount(<JobStatusIcon status="running" />);
|
|
||||||
expect(wrapper).toHaveLength(1);
|
|
||||||
expect(wrapper.find('JobStatusIcon__RunningJob')).toHaveLength(1);
|
|
||||||
});
|
|
||||||
test('renders waiting job', () => {
|
|
||||||
const wrapper = mount(<JobStatusIcon status="waiting" />);
|
|
||||||
expect(wrapper).toHaveLength(1);
|
|
||||||
expect(wrapper.find('JobStatusIcon__WaitingJob')).toHaveLength(1);
|
|
||||||
});
|
|
||||||
test('renders failed job', () => {
|
|
||||||
const wrapper = mount(<JobStatusIcon status="failed" />);
|
|
||||||
expect(wrapper).toHaveLength(1);
|
|
||||||
expect(wrapper.find('JobStatusIcon__FailedTop')).toHaveLength(1);
|
|
||||||
expect(wrapper.find('JobStatusIcon__FailedBottom')).toHaveLength(1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -2,7 +2,7 @@ import React, { Fragment } from 'react';
|
|||||||
import { arrayOf, object } from 'prop-types';
|
import { arrayOf, object } from 'prop-types';
|
||||||
import { withI18n } from '@lingui/react';
|
import { withI18n } from '@lingui/react';
|
||||||
import { Link as _Link } from 'react-router-dom';
|
import { Link as _Link } from 'react-router-dom';
|
||||||
import { JobStatusIcon } from '@components/Sparkline';
|
import { StatusIcon } from '@components/Sparkline';
|
||||||
import { Tooltip } from '@patternfly/react-core';
|
import { Tooltip } from '@patternfly/react-core';
|
||||||
import styled from 'styled-components';
|
import styled from 'styled-components';
|
||||||
import { t } from '@lingui/macro';
|
import { t } from '@lingui/macro';
|
||||||
@@ -34,7 +34,7 @@ const Sparkline = ({ i18n, jobs }) => {
|
|||||||
return jobs.map(job => (
|
return jobs.map(job => (
|
||||||
<Tooltip position="top" content={generateTooltip(job)} key={job.id}>
|
<Tooltip position="top" content={generateTooltip(job)} key={job.id}>
|
||||||
<Link to={`/jobs/${JOB_TYPE_URL_SEGMENTS[job.type]}/${job.id}`}>
|
<Link to={`/jobs/${JOB_TYPE_URL_SEGMENTS[job.type]}/${job.id}`}>
|
||||||
<JobStatusIcon status={job.status} />
|
<StatusIcon status={job.status} />
|
||||||
</Link>
|
</Link>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
));
|
));
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ describe('Sparkline', () => {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
const wrapper = mountWithContexts(<Sparkline jobs={jobs} />);
|
const wrapper = mountWithContexts(<Sparkline jobs={jobs} />);
|
||||||
expect(wrapper.find('JobStatusIcon')).toHaveLength(2);
|
expect(wrapper.find('StatusIcon')).toHaveLength(2);
|
||||||
expect(wrapper.find('Tooltip')).toHaveLength(2);
|
expect(wrapper.find('Tooltip')).toHaveLength(2);
|
||||||
expect(wrapper.find('Link')).toHaveLength(2);
|
expect(wrapper.find('Link')).toHaveLength(2);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -16,6 +16,18 @@ const Wrapper = styled.div`
|
|||||||
height: 14px;
|
height: 14px;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
const WhiteTop = styled.div`
|
||||||
|
border: 1px solid #b7b7b7;
|
||||||
|
border-bottom: 0;
|
||||||
|
background: #ffffff;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const WhiteBottom = styled.div`
|
||||||
|
border: 1px solid #b7b7b7;
|
||||||
|
border-top: 0;
|
||||||
|
background: #ffffff;
|
||||||
|
`;
|
||||||
|
|
||||||
const RunningJob = styled(Wrapper)`
|
const RunningJob = styled(Wrapper)`
|
||||||
background-color: #5cb85c;
|
background-color: #5cb85c;
|
||||||
padding-right: 0px;
|
padding-right: 0px;
|
||||||
@@ -39,24 +51,29 @@ const FinishedJob = styled(Wrapper)`
|
|||||||
const SuccessfulTop = styled.div`
|
const SuccessfulTop = styled.div`
|
||||||
background-color: #5cb85c;
|
background-color: #5cb85c;
|
||||||
`;
|
`;
|
||||||
|
const SuccessfulBottom = styled(WhiteBottom)``;
|
||||||
|
|
||||||
const SuccessfulBottom = styled.div`
|
const FailedTop = styled(WhiteTop)``;
|
||||||
border: 1px solid #b7b7b7;
|
|
||||||
border-top: 0;
|
|
||||||
background: #ffffff;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const FailedTop = styled.div`
|
|
||||||
border: 1px solid #b7b7b7;
|
|
||||||
border-bottom: 0;
|
|
||||||
background: #ffffff;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const FailedBottom = styled.div`
|
const FailedBottom = styled.div`
|
||||||
background-color: #d9534f;
|
background-color: #d9534f;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const JobStatusIcon = ({ status, ...props }) => {
|
const UnreachableTop = styled(WhiteTop)``;
|
||||||
|
const UnreachableBottom = styled.div`
|
||||||
|
background-color: #ff0000;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const ChangedTop = styled(WhiteTop)``;
|
||||||
|
const ChangedBottom = styled.div`
|
||||||
|
background-color: #ff9900;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const SkippedTop = styled(WhiteTop)``;
|
||||||
|
const SkippedBottom = styled.div`
|
||||||
|
background-color: #2dbaba;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const StatusIcon = ({ status, ...props }) => {
|
||||||
return (
|
return (
|
||||||
<div {...props}>
|
<div {...props}>
|
||||||
{status === 'running' && <RunningJob />}
|
{status === 'running' && <RunningJob />}
|
||||||
@@ -69,18 +86,36 @@ const JobStatusIcon = ({ status, ...props }) => {
|
|||||||
<FailedBottom />
|
<FailedBottom />
|
||||||
</FinishedJob>
|
</FinishedJob>
|
||||||
)}
|
)}
|
||||||
{status === 'successful' && (
|
{(status === 'successful' || status === 'ok') && (
|
||||||
<FinishedJob>
|
<FinishedJob>
|
||||||
<SuccessfulTop />
|
<SuccessfulTop />
|
||||||
<SuccessfulBottom />
|
<SuccessfulBottom />
|
||||||
</FinishedJob>
|
</FinishedJob>
|
||||||
)}
|
)}
|
||||||
|
{status === 'changed' && (
|
||||||
|
<FinishedJob>
|
||||||
|
<ChangedTop />
|
||||||
|
<ChangedBottom />
|
||||||
|
</FinishedJob>
|
||||||
|
)}
|
||||||
|
{status === 'skipped' && (
|
||||||
|
<FinishedJob>
|
||||||
|
<SkippedTop />
|
||||||
|
<SkippedBottom />
|
||||||
|
</FinishedJob>
|
||||||
|
)}
|
||||||
|
{status === 'unreachable' && (
|
||||||
|
<FinishedJob>
|
||||||
|
<UnreachableTop />
|
||||||
|
<UnreachableBottom />
|
||||||
|
</FinishedJob>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
JobStatusIcon.propTypes = {
|
StatusIcon.propTypes = {
|
||||||
status: string.isRequired,
|
status: string.isRequired,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default JobStatusIcon;
|
export default StatusIcon;
|
||||||
59
awx/ui_next/src/components/Sparkline/StatusIcon.test.jsx
Normal file
59
awx/ui_next/src/components/Sparkline/StatusIcon.test.jsx
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { mount } from 'enzyme';
|
||||||
|
import StatusIcon from './StatusIcon';
|
||||||
|
|
||||||
|
describe('StatusIcon', () => {
|
||||||
|
test('renders the successful status', () => {
|
||||||
|
const wrapper = mount(<StatusIcon status="successful" />);
|
||||||
|
expect(wrapper).toHaveLength(1);
|
||||||
|
expect(wrapper.find('StatusIcon__SuccessfulTop')).toHaveLength(1);
|
||||||
|
expect(wrapper.find('StatusIcon__SuccessfulBottom')).toHaveLength(1);
|
||||||
|
});
|
||||||
|
test('renders running status', () => {
|
||||||
|
const wrapper = mount(<StatusIcon status="running" />);
|
||||||
|
expect(wrapper).toHaveLength(1);
|
||||||
|
expect(wrapper.find('StatusIcon__RunningJob')).toHaveLength(1);
|
||||||
|
});
|
||||||
|
test('renders waiting status', () => {
|
||||||
|
const wrapper = mount(<StatusIcon status="waiting" />);
|
||||||
|
expect(wrapper).toHaveLength(1);
|
||||||
|
expect(wrapper.find('StatusIcon__WaitingJob')).toHaveLength(1);
|
||||||
|
});
|
||||||
|
test('renders failed status', () => {
|
||||||
|
const wrapper = mount(<StatusIcon status="failed" />);
|
||||||
|
expect(wrapper).toHaveLength(1);
|
||||||
|
expect(wrapper.find('StatusIcon__FailedTop')).toHaveLength(1);
|
||||||
|
expect(wrapper.find('StatusIcon__FailedBottom')).toHaveLength(1);
|
||||||
|
});
|
||||||
|
test('renders a successful status when host status is "ok"', () => {
|
||||||
|
const wrapper = mount(<StatusIcon status="ok" />);
|
||||||
|
wrapper.debug();
|
||||||
|
expect(wrapper).toHaveLength(1);
|
||||||
|
expect(wrapper.find('StatusIcon__SuccessfulTop')).toHaveLength(1);
|
||||||
|
expect(wrapper.find('StatusIcon__SuccessfulBottom')).toHaveLength(1);
|
||||||
|
});
|
||||||
|
test('renders "failed" host status', () => {
|
||||||
|
const wrapper = mount(<StatusIcon status="failed" />);
|
||||||
|
expect(wrapper).toHaveLength(1);
|
||||||
|
expect(wrapper.find('StatusIcon__FailedTop')).toHaveLength(1);
|
||||||
|
expect(wrapper.find('StatusIcon__FailedBottom')).toHaveLength(1);
|
||||||
|
});
|
||||||
|
test('renders "changed" host status', () => {
|
||||||
|
const wrapper = mount(<StatusIcon status="changed" />);
|
||||||
|
expect(wrapper).toHaveLength(1);
|
||||||
|
expect(wrapper.find('StatusIcon__ChangedTop')).toHaveLength(1);
|
||||||
|
expect(wrapper.find('StatusIcon__ChangedBottom')).toHaveLength(1);
|
||||||
|
});
|
||||||
|
test('renders "skipped" host status', () => {
|
||||||
|
const wrapper = mount(<StatusIcon status="skipped" />);
|
||||||
|
expect(wrapper).toHaveLength(1);
|
||||||
|
expect(wrapper.find('StatusIcon__SkippedTop')).toHaveLength(1);
|
||||||
|
expect(wrapper.find('StatusIcon__SkippedBottom')).toHaveLength(1);
|
||||||
|
});
|
||||||
|
test('renders "unreachable" host status', () => {
|
||||||
|
const wrapper = mount(<StatusIcon status="unreachable" />);
|
||||||
|
expect(wrapper).toHaveLength(1);
|
||||||
|
expect(wrapper.find('StatusIcon__UnreachableTop')).toHaveLength(1);
|
||||||
|
expect(wrapper.find('StatusIcon__UnreachableBottom')).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,2 +1,2 @@
|
|||||||
export { default as Sparkline } from './Sparkline';
|
export { default as Sparkline } from './Sparkline';
|
||||||
export { default as JobStatusIcon } from './JobStatusIcon';
|
export { default as StatusIcon } from './StatusIcon';
|
||||||
|
|||||||
254
awx/ui_next/src/screens/Job/JobOutput/HostEventModal.jsx
Normal file
254
awx/ui_next/src/screens/Job/JobOutput/HostEventModal.jsx
Normal file
@@ -0,0 +1,254 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Modal as PFModal,
|
||||||
|
Tab,
|
||||||
|
Tabs as PFTabs,
|
||||||
|
} from '@patternfly/react-core';
|
||||||
|
import CodeMirrorInput from '@components/CodeMirrorInput';
|
||||||
|
import ContentEmpty from '@components/ContentEmpty';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import { DetailList, Detail } from '@components/DetailList';
|
||||||
|
import { StatusIcon } from '@components/Sparkline';
|
||||||
|
import { withI18n } from '@lingui/react';
|
||||||
|
import { t } from '@lingui/macro';
|
||||||
|
import styled from 'styled-components';
|
||||||
|
import Entities from 'html-entities';
|
||||||
|
|
||||||
|
const entities = new Entities.AllHtmlEntities();
|
||||||
|
|
||||||
|
const Modal = styled(PFModal)`
|
||||||
|
--pf-c-modal-box__footer--MarginTop: 0;
|
||||||
|
align-self: flex-start;
|
||||||
|
margin-top: 200px;
|
||||||
|
.pf-c-modal-box__body {
|
||||||
|
overflow-y: hidden;
|
||||||
|
}
|
||||||
|
.pf-c-tab-content {
|
||||||
|
padding: 24px 0;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const HostNameDetailValue = styled.div`
|
||||||
|
align-items: center;
|
||||||
|
display: inline-flex;
|
||||||
|
> div {
|
||||||
|
margin-right: 10px;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Tabs = styled(PFTabs)`
|
||||||
|
--pf-c-tabs__button--PaddingLeft: 20px;
|
||||||
|
--pf-c-tabs__button--PaddingRight: 20px;
|
||||||
|
|
||||||
|
.pf-c-tabs__list {
|
||||||
|
li:first-of-type .pf-c-tabs__button {
|
||||||
|
&::after {
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:not(.pf-c-tabs__item)::before {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
content: '';
|
||||||
|
border-bottom: solid var(--pf-c-tabs__item--BorderColor);
|
||||||
|
border-width: var(--pf-c-tabs__item--BorderWidth) 0
|
||||||
|
var(--pf-c-tabs__item--BorderWidth) 0;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const processEventStatus = event => {
|
||||||
|
let status = null;
|
||||||
|
if (event.event === 'runner_on_unreachable') {
|
||||||
|
status = 'unreachable';
|
||||||
|
}
|
||||||
|
// equiv to 'runner_on_error' && 'runner_on_failed'
|
||||||
|
if (event.failed) {
|
||||||
|
status = 'failed';
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
event.event === 'runner_on_ok' ||
|
||||||
|
event.event === 'runner_on_async_ok' ||
|
||||||
|
event.event === 'runner_item_on_ok'
|
||||||
|
) {
|
||||||
|
status = 'ok';
|
||||||
|
}
|
||||||
|
// if 'ok' and 'changed' are both true, show 'changed'
|
||||||
|
if (event.changed) {
|
||||||
|
status = 'changed';
|
||||||
|
}
|
||||||
|
if (event.event === 'runner_on_skipped') {
|
||||||
|
status = 'skipped';
|
||||||
|
}
|
||||||
|
return status;
|
||||||
|
};
|
||||||
|
|
||||||
|
const processCodeMirrorValue = value => {
|
||||||
|
let codeMirrorValue;
|
||||||
|
if (value === undefined) {
|
||||||
|
codeMirrorValue = false;
|
||||||
|
} else if (value === '') {
|
||||||
|
codeMirrorValue = ' ';
|
||||||
|
} else if (typeof value === 'string') {
|
||||||
|
codeMirrorValue = entities.encode(value);
|
||||||
|
} else {
|
||||||
|
codeMirrorValue = value;
|
||||||
|
}
|
||||||
|
return codeMirrorValue;
|
||||||
|
};
|
||||||
|
|
||||||
|
const processStdOutValue = hostEvent => {
|
||||||
|
const { taskAction, res } = hostEvent.event_data;
|
||||||
|
let stdOut;
|
||||||
|
if (taskAction === 'debug' && res.result && res.result.stdout) {
|
||||||
|
stdOut = res.result.stdout;
|
||||||
|
} else if (
|
||||||
|
taskAction === 'yum' &&
|
||||||
|
res.results &&
|
||||||
|
Array.isArray(res.results)
|
||||||
|
) {
|
||||||
|
[stdOut] = res.results;
|
||||||
|
} else {
|
||||||
|
stdOut = res.stdout;
|
||||||
|
}
|
||||||
|
return stdOut;
|
||||||
|
};
|
||||||
|
|
||||||
|
function HostEventModal({ onClose, hostEvent = {}, isOpen = false, i18n }) {
|
||||||
|
const [hostStatus, setHostStatus] = useState(null);
|
||||||
|
const [activeTabKey, setActiveTabKey] = useState(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setHostStatus(processEventStatus(hostEvent));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleTabClick = (event, tabIndex) => {
|
||||||
|
setActiveTabKey(tabIndex);
|
||||||
|
};
|
||||||
|
|
||||||
|
const jsonObj = processCodeMirrorValue(hostEvent.event_data.res);
|
||||||
|
const stdErr = processCodeMirrorValue(hostEvent.event_data.res.stderr);
|
||||||
|
const stdOut = processCodeMirrorValue(processStdOutValue(hostEvent));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
isLarge
|
||||||
|
isOpen={isOpen}
|
||||||
|
onClose={onClose}
|
||||||
|
title={i18n._(t`Host Details`)}
|
||||||
|
actions={[
|
||||||
|
<Button key="cancel" variant="secondary" onClick={onClose}>
|
||||||
|
{i18n._(t`Close`)}
|
||||||
|
</Button>,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Tabs
|
||||||
|
aria-label={i18n._(t`Tabs`)}
|
||||||
|
activeKey={activeTabKey}
|
||||||
|
onSelect={handleTabClick}
|
||||||
|
>
|
||||||
|
<Tab
|
||||||
|
aria-label={i18n._(t`Details tab`)}
|
||||||
|
eventKey={0}
|
||||||
|
title={i18n._(t`Details`)}
|
||||||
|
>
|
||||||
|
<DetailList style={{ alignItems: 'center' }} gutter="sm">
|
||||||
|
<Detail
|
||||||
|
label={i18n._(t`Host Name`)}
|
||||||
|
value={
|
||||||
|
<HostNameDetailValue>
|
||||||
|
{hostStatus ? <StatusIcon status={hostStatus} /> : null}
|
||||||
|
{hostEvent.host_name}
|
||||||
|
</HostNameDetailValue>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Detail label={i18n._(t`Play`)} value={hostEvent.play} />
|
||||||
|
<Detail label={i18n._(t`Task`)} value={hostEvent.task} />
|
||||||
|
<Detail
|
||||||
|
label={i18n._(t`Module`)}
|
||||||
|
value={
|
||||||
|
hostEvent.event_data.task_action || i18n._(t`No result found`)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Detail
|
||||||
|
label={i18n._(t`Command`)}
|
||||||
|
value={hostEvent.event_data.res.cmd}
|
||||||
|
/>
|
||||||
|
</DetailList>
|
||||||
|
</Tab>
|
||||||
|
<Tab
|
||||||
|
eventKey={1}
|
||||||
|
title={i18n._(t`JSON`)}
|
||||||
|
aria-label={i18n._(t`JSON tab`)}
|
||||||
|
>
|
||||||
|
{activeTabKey === 1 && jsonObj ? (
|
||||||
|
<CodeMirrorInput
|
||||||
|
mode="javascript"
|
||||||
|
readOnly
|
||||||
|
value={JSON.stringify(jsonObj, null, 2)}
|
||||||
|
onChange={() => {}}
|
||||||
|
rows={20}
|
||||||
|
hasErrors={false}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<ContentEmpty title={i18n._(t`No JSON Available`)} />
|
||||||
|
)}
|
||||||
|
</Tab>
|
||||||
|
<Tab
|
||||||
|
eventKey={2}
|
||||||
|
title={i18n._(t`Standard Out`)}
|
||||||
|
aria-label={i18n._(t`Standard out tab`)}
|
||||||
|
>
|
||||||
|
{activeTabKey === 2 && stdOut ? (
|
||||||
|
<CodeMirrorInput
|
||||||
|
mode="javascript"
|
||||||
|
readOnly
|
||||||
|
value={stdOut}
|
||||||
|
onChange={() => {}}
|
||||||
|
rows={20}
|
||||||
|
hasErrors={false}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<ContentEmpty title={i18n._(t`No Standard Out Available`)} />
|
||||||
|
)}
|
||||||
|
</Tab>
|
||||||
|
<Tab
|
||||||
|
eventKey={3}
|
||||||
|
title={i18n._(t`Standard Error`)}
|
||||||
|
aria-label={i18n._(t`Standard error tab`)}
|
||||||
|
>
|
||||||
|
{activeTabKey === 3 && stdErr ? (
|
||||||
|
<CodeMirrorInput
|
||||||
|
mode="javascript"
|
||||||
|
readOnly
|
||||||
|
onChange={() => {}}
|
||||||
|
value={stdErr}
|
||||||
|
hasErrors={false}
|
||||||
|
rows={20}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<ContentEmpty title={i18n._(t`No Standard Error Available`)} />
|
||||||
|
)}
|
||||||
|
</Tab>
|
||||||
|
</Tabs>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default withI18n()(HostEventModal);
|
||||||
|
|
||||||
|
HostEventModal.propTypes = {
|
||||||
|
onClose: PropTypes.func.isRequired,
|
||||||
|
hostEvent: PropTypes.shape({}),
|
||||||
|
isOpen: PropTypes.bool,
|
||||||
|
};
|
||||||
|
|
||||||
|
HostEventModal.defaultProps = {
|
||||||
|
hostEvent: null,
|
||||||
|
isOpen: false,
|
||||||
|
};
|
||||||
297
awx/ui_next/src/screens/Job/JobOutput/HostEventModal.test.jsx
Normal file
297
awx/ui_next/src/screens/Job/JobOutput/HostEventModal.test.jsx
Normal file
@@ -0,0 +1,297 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { mountWithContexts } from '@testUtils/enzymeHelpers';
|
||||||
|
import HostEventModal from './HostEventModal';
|
||||||
|
|
||||||
|
const hostEvent = {
|
||||||
|
changed: true,
|
||||||
|
event: 'runner_on_ok',
|
||||||
|
event_data: {
|
||||||
|
host: 'foo',
|
||||||
|
play: 'all',
|
||||||
|
playbook: 'run_command.yml',
|
||||||
|
res: {
|
||||||
|
ansible_loop_var: 'item',
|
||||||
|
changed: true,
|
||||||
|
item: '1',
|
||||||
|
msg: 'This is a debug message: 1',
|
||||||
|
stdout:
|
||||||
|
' total used free shared buff/cache available\nMem: 7973 3005 960 30 4007 4582\nSwap: 1023 0 1023',
|
||||||
|
cmd: ['free', '-m'],
|
||||||
|
stderr_lines: [],
|
||||||
|
stdout_lines: [
|
||||||
|
' total used free shared buff/cache available',
|
||||||
|
'Mem: 7973 3005 960 30 4007 4582',
|
||||||
|
'Swap: 1023 0 1023',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
task: 'command',
|
||||||
|
task_action: 'command',
|
||||||
|
},
|
||||||
|
event_display: 'Host OK',
|
||||||
|
event_level: 3,
|
||||||
|
failed: false,
|
||||||
|
host: 1,
|
||||||
|
host_name: 'foo',
|
||||||
|
id: 123,
|
||||||
|
job: 4,
|
||||||
|
play: 'all',
|
||||||
|
playbook: 'run_command.yml',
|
||||||
|
stdout: `stdout: "[0;33mchanged: [localhost] => {"changed": true, "cmd": ["free", "-m"], "delta": "0:00:01.479609", "end": "2019-09-10 14:21:45.469533", "rc": 0, "start": "2019-09-10 14:21:43.989924", "stderr": "", "stderr_lines": [], "stdout": " total used free shared buff/cache available\nMem: 7973 3005 960 30 4007 4582\nSwap: 1023 0 1023", "stdout_lines": [" total used free shared buff/cache available", "Mem: 7973 3005 960 30 4007 4582", "Swap: 1023 0 1023"]}[0m"
|
||||||
|
`,
|
||||||
|
task: 'command',
|
||||||
|
type: 'job_event',
|
||||||
|
url: '/api/v2/job_events/123/',
|
||||||
|
};
|
||||||
|
|
||||||
|
/* eslint-disable no-useless-escape */
|
||||||
|
const jsonValue = `{
|
||||||
|
\"ansible_loop_var\": \"item\",
|
||||||
|
\"changed\": true,
|
||||||
|
\"item\": \"1\",
|
||||||
|
\"msg\": \"This is a debug message: 1\",
|
||||||
|
\"stdout\": \" total used free shared buff/cache available\\nMem: 7973 3005 960 30 4007 4582\\nSwap: 1023 0 1023\",
|
||||||
|
\"cmd\": [
|
||||||
|
\"free\",
|
||||||
|
\"-m\"
|
||||||
|
],
|
||||||
|
\"stderr_lines\": [],
|
||||||
|
\"stdout_lines\": [
|
||||||
|
\" total used free shared buff/cache available\",
|
||||||
|
\"Mem: 7973 3005 960 30 4007 4582\",
|
||||||
|
\"Swap: 1023 0 1023\"
|
||||||
|
]
|
||||||
|
}`;
|
||||||
|
|
||||||
|
let detailsSection;
|
||||||
|
let jsonSection;
|
||||||
|
let standardOutSection;
|
||||||
|
let standardErrorSection;
|
||||||
|
|
||||||
|
const findSections = wrapper => {
|
||||||
|
detailsSection = wrapper.find('section').at(0);
|
||||||
|
jsonSection = wrapper.find('section').at(1);
|
||||||
|
standardOutSection = wrapper.find('section').at(2);
|
||||||
|
standardErrorSection = wrapper.find('section').at(3);
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('HostEventModal', () => {
|
||||||
|
test('initially renders successfully', () => {
|
||||||
|
const wrapper = mountWithContexts(
|
||||||
|
<HostEventModal hostEvent={hostEvent} onClose={() => {}} />
|
||||||
|
);
|
||||||
|
expect(wrapper).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should render all tabs', () => {
|
||||||
|
const wrapper = mountWithContexts(
|
||||||
|
<HostEventModal hostEvent={hostEvent} onClose={() => {}} isOpen />
|
||||||
|
);
|
||||||
|
|
||||||
|
/* eslint-disable react/button-has-type */
|
||||||
|
expect(
|
||||||
|
wrapper
|
||||||
|
.find('Tabs')
|
||||||
|
.containsAllMatchingElements([
|
||||||
|
<button aria-label="Details tab">Details</button>,
|
||||||
|
<button aria-label="JSON tab">JSON</button>,
|
||||||
|
<button aria-label="Standard out tab">Standard Out</button>,
|
||||||
|
<button aria-label="Standard error tab">Standard Error</button>,
|
||||||
|
])
|
||||||
|
).toEqual(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should show details tab content on mount', () => {
|
||||||
|
const wrapper = mountWithContexts(
|
||||||
|
<HostEventModal hostEvent={hostEvent} onClose={() => {}} isOpen />
|
||||||
|
);
|
||||||
|
findSections(wrapper);
|
||||||
|
expect(detailsSection.find('TextList').length).toBe(1);
|
||||||
|
|
||||||
|
function assertDetail(label, value) {
|
||||||
|
expect(wrapper.find(`Detail[label="${label}"] dt`).text()).toBe(label);
|
||||||
|
expect(wrapper.find(`Detail[label="${label}"] dd`).text()).toBe(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
assertDetail('Host Name', 'foo');
|
||||||
|
assertDetail('Play', 'all');
|
||||||
|
assertDetail('Task', 'command');
|
||||||
|
assertDetail('Module', 'command');
|
||||||
|
assertDetail('Command', 'free-m');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should display successful host status icon', () => {
|
||||||
|
const successfulHostEvent = { ...hostEvent, changed: false };
|
||||||
|
const wrapper = mountWithContexts(
|
||||||
|
<HostEventModal
|
||||||
|
hostEvent={successfulHostEvent}
|
||||||
|
onClose={() => {}}
|
||||||
|
isOpen
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
const icon = wrapper.find('StatusIcon');
|
||||||
|
expect(icon.prop('status')).toBe('ok');
|
||||||
|
expect(icon.find('StatusIcon__SuccessfulTop').length).toBe(1);
|
||||||
|
expect(icon.find('StatusIcon__SuccessfulBottom').length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should display skipped host status icon', () => {
|
||||||
|
const skippedHostEvent = { ...hostEvent, event: 'runner_on_skipped' };
|
||||||
|
const wrapper = mountWithContexts(
|
||||||
|
<HostEventModal hostEvent={skippedHostEvent} onClose={() => {}} isOpen />
|
||||||
|
);
|
||||||
|
|
||||||
|
const icon = wrapper.find('StatusIcon');
|
||||||
|
expect(icon.prop('status')).toBe('skipped');
|
||||||
|
expect(icon.find('StatusIcon__SkippedTop').length).toBe(1);
|
||||||
|
expect(icon.find('StatusIcon__SkippedBottom').length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should display unreachable host status icon', () => {
|
||||||
|
const unreachableHostEvent = {
|
||||||
|
...hostEvent,
|
||||||
|
event: 'runner_on_unreachable',
|
||||||
|
changed: false,
|
||||||
|
};
|
||||||
|
const wrapper = mountWithContexts(
|
||||||
|
<HostEventModal
|
||||||
|
hostEvent={unreachableHostEvent}
|
||||||
|
onClose={() => {}}
|
||||||
|
isOpen
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const icon = wrapper.find('StatusIcon');
|
||||||
|
expect(icon.prop('status')).toBe('unreachable');
|
||||||
|
expect(icon.find('StatusIcon__UnreachableTop').length).toBe(1);
|
||||||
|
expect(icon.find('StatusIcon__UnreachableBottom').length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should display failed host status icon', () => {
|
||||||
|
const unreachableHostEvent = {
|
||||||
|
...hostEvent,
|
||||||
|
changed: false,
|
||||||
|
failed: true,
|
||||||
|
event: 'runner_on_failed',
|
||||||
|
};
|
||||||
|
const wrapper = mountWithContexts(
|
||||||
|
<HostEventModal
|
||||||
|
hostEvent={unreachableHostEvent}
|
||||||
|
onClose={() => {}}
|
||||||
|
isOpen
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const icon = wrapper.find('StatusIcon');
|
||||||
|
expect(icon.prop('status')).toBe('failed');
|
||||||
|
expect(icon.find('StatusIcon__FailedTop').length).toBe(1);
|
||||||
|
expect(icon.find('StatusIcon__FailedBottom').length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should display JSON tab content on tab click', () => {
|
||||||
|
const wrapper = mountWithContexts(
|
||||||
|
<HostEventModal hostEvent={hostEvent} onClose={() => {}} isOpen />
|
||||||
|
);
|
||||||
|
|
||||||
|
findSections(wrapper);
|
||||||
|
expect(jsonSection.find('EmptyState').length).toBe(1);
|
||||||
|
wrapper.find('button[aria-label="JSON tab"]').simulate('click');
|
||||||
|
findSections(wrapper);
|
||||||
|
expect(jsonSection.find('CodeMirrorInput').length).toBe(1);
|
||||||
|
|
||||||
|
const codemirror = jsonSection.find('CodeMirrorInput Controlled');
|
||||||
|
expect(codemirror.prop('mode')).toBe('javascript');
|
||||||
|
expect(codemirror.prop('options').readOnly).toBe(true);
|
||||||
|
expect(codemirror.prop('value')).toEqual(jsonValue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should display Standard Out tab content on tab click', () => {
|
||||||
|
const wrapper = mountWithContexts(
|
||||||
|
<HostEventModal hostEvent={hostEvent} onClose={() => {}} isOpen />
|
||||||
|
);
|
||||||
|
|
||||||
|
findSections(wrapper);
|
||||||
|
expect(standardOutSection.find('EmptyState').length).toBe(1);
|
||||||
|
wrapper.find('button[aria-label="Standard out tab"]').simulate('click');
|
||||||
|
findSections(wrapper);
|
||||||
|
expect(standardOutSection.find('CodeMirrorInput').length).toBe(1);
|
||||||
|
|
||||||
|
const codemirror = standardOutSection.find('CodeMirrorInput Controlled');
|
||||||
|
expect(codemirror.prop('mode')).toBe('javascript');
|
||||||
|
expect(codemirror.prop('options').readOnly).toBe(true);
|
||||||
|
expect(codemirror.prop('value')).toEqual(hostEvent.event_data.res.stdout);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should display Standard Error tab content on tab click', () => {
|
||||||
|
const hostEventError = {
|
||||||
|
...hostEvent,
|
||||||
|
event_data: {
|
||||||
|
res: {
|
||||||
|
stderr: '',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const wrapper = mountWithContexts(
|
||||||
|
<HostEventModal hostEvent={hostEventError} onClose={() => {}} isOpen />
|
||||||
|
);
|
||||||
|
findSections(wrapper);
|
||||||
|
expect(standardErrorSection.find('EmptyState').length).toBe(1);
|
||||||
|
wrapper.find('button[aria-label="Standard error tab"]').simulate('click');
|
||||||
|
findSections(wrapper);
|
||||||
|
expect(standardErrorSection.find('CodeMirrorInput').length).toBe(1);
|
||||||
|
|
||||||
|
const codemirror = standardErrorSection.find('CodeMirrorInput Controlled');
|
||||||
|
expect(codemirror.prop('mode')).toBe('javascript');
|
||||||
|
expect(codemirror.prop('options').readOnly).toBe(true);
|
||||||
|
expect(codemirror.prop('value')).toEqual(' ');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should call onClose when close button is clicked', () => {
|
||||||
|
const onClose = jest.fn();
|
||||||
|
const wrapper = mountWithContexts(
|
||||||
|
<HostEventModal hostEvent={hostEvent} onClose={onClose} isOpen />
|
||||||
|
);
|
||||||
|
const closeButton = wrapper.find('ModalBoxFooter Button');
|
||||||
|
closeButton.simulate('click');
|
||||||
|
expect(onClose).toBeCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should render standard out of debug task', () => {
|
||||||
|
const debugTaskAction = {
|
||||||
|
...hostEvent,
|
||||||
|
event_data: {
|
||||||
|
taskAction: 'debug',
|
||||||
|
res: {
|
||||||
|
result: {
|
||||||
|
stdout: 'foo bar',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const wrapper = mountWithContexts(
|
||||||
|
<HostEventModal hostEvent={debugTaskAction} onClose={() => {}} isOpen />
|
||||||
|
);
|
||||||
|
wrapper.find('button[aria-label="Standard out tab"]').simulate('click');
|
||||||
|
findSections(wrapper);
|
||||||
|
const codemirror = standardOutSection.find('CodeMirrorInput Controlled');
|
||||||
|
expect(codemirror.prop('value')).toEqual('foo bar');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should render standard out of yum task', () => {
|
||||||
|
const yumTaskAction = {
|
||||||
|
...hostEvent,
|
||||||
|
event_data: {
|
||||||
|
taskAction: 'yum',
|
||||||
|
res: {
|
||||||
|
results: ['baz', 'bar'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const wrapper = mountWithContexts(
|
||||||
|
<HostEventModal hostEvent={yumTaskAction} onClose={() => {}} isOpen />
|
||||||
|
);
|
||||||
|
wrapper.find('button[aria-label="Standard out tab"]').simulate('click');
|
||||||
|
findSections(wrapper);
|
||||||
|
const codemirror = standardOutSection.find('CodeMirrorInput Controlled');
|
||||||
|
expect(codemirror.prop('value')).toEqual('baz');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -77,6 +77,8 @@ function JobEvent({
|
|||||||
counter,
|
counter,
|
||||||
created,
|
created,
|
||||||
event,
|
event,
|
||||||
|
isClickable,
|
||||||
|
onJobEventClick,
|
||||||
stdout,
|
stdout,
|
||||||
start_line,
|
start_line,
|
||||||
style,
|
style,
|
||||||
@@ -88,8 +90,10 @@ function JobEvent({
|
|||||||
({ lineNumber, html }) =>
|
({ lineNumber, html }) =>
|
||||||
lineNumber >= 0 && (
|
lineNumber >= 0 && (
|
||||||
<JobEventLine
|
<JobEventLine
|
||||||
|
onClick={isClickable ? onJobEventClick : undefined}
|
||||||
key={`${counter}-${lineNumber}`}
|
key={`${counter}-${lineNumber}`}
|
||||||
isFirst={lineNumber === 0}
|
isFirst={lineNumber === 0}
|
||||||
|
isClickable={isClickable}
|
||||||
>
|
>
|
||||||
<JobEventLineToggle />
|
<JobEventLineToggle />
|
||||||
<JobEventLineNumber>{lineNumber}</JobEventLineNumber>
|
<JobEventLineNumber>{lineNumber}</JobEventLineNumber>
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import ContentLoading from '@components/ContentLoading';
|
|||||||
import JobEvent from './JobEvent';
|
import JobEvent from './JobEvent';
|
||||||
import JobEventSkeleton from './JobEventSkeleton';
|
import JobEventSkeleton from './JobEventSkeleton';
|
||||||
import MenuControls from './MenuControls';
|
import MenuControls from './MenuControls';
|
||||||
|
import HostEventModal from './HostEventModal';
|
||||||
|
|
||||||
const OutputHeader = styled.div`
|
const OutputHeader = styled.div`
|
||||||
font-weight: var(--pf-global--FontWeight--bold);
|
font-weight: var(--pf-global--FontWeight--bold);
|
||||||
@@ -59,6 +60,8 @@ class JobOutput extends Component {
|
|||||||
results: {},
|
results: {},
|
||||||
currentlyLoading: [],
|
currentlyLoading: [],
|
||||||
remoteRowCount: 0,
|
remoteRowCount: 0,
|
||||||
|
isHostModalOpen: false,
|
||||||
|
hostEvent: {},
|
||||||
};
|
};
|
||||||
|
|
||||||
this.cache = new CellMeasurerCache({
|
this.cache = new CellMeasurerCache({
|
||||||
@@ -69,6 +72,8 @@ class JobOutput extends Component {
|
|||||||
this._isMounted = false;
|
this._isMounted = false;
|
||||||
this.loadJobEvents = this.loadJobEvents.bind(this);
|
this.loadJobEvents = this.loadJobEvents.bind(this);
|
||||||
this.rowRenderer = this.rowRenderer.bind(this);
|
this.rowRenderer = this.rowRenderer.bind(this);
|
||||||
|
this.handleHostEventClick = this.handleHostEventClick.bind(this);
|
||||||
|
this.handleHostModalClose = this.handleHostModalClose.bind(this);
|
||||||
this.handleScrollFirst = this.handleScrollFirst.bind(this);
|
this.handleScrollFirst = this.handleScrollFirst.bind(this);
|
||||||
this.handleScrollLast = this.handleScrollLast.bind(this);
|
this.handleScrollLast = this.handleScrollLast.bind(this);
|
||||||
this.handleScrollNext = this.handleScrollNext.bind(this);
|
this.handleScrollNext = this.handleScrollNext.bind(this);
|
||||||
@@ -150,8 +155,39 @@ class JobOutput extends Component {
|
|||||||
return currentlyLoading.includes(index);
|
return currentlyLoading.includes(index);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
handleHostEventClick(hostEvent) {
|
||||||
|
this.setState({
|
||||||
|
isHostModalOpen: true,
|
||||||
|
hostEvent,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
handleHostModalClose() {
|
||||||
|
this.setState({
|
||||||
|
isHostModalOpen: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
rowRenderer({ index, parent, key, style }) {
|
rowRenderer({ index, parent, key, style }) {
|
||||||
const { results } = this.state;
|
const { results } = this.state;
|
||||||
|
|
||||||
|
const isHostEvent = jobEvent => {
|
||||||
|
const { event, event_data, host, type } = jobEvent;
|
||||||
|
let isHost;
|
||||||
|
if (typeof host === 'number' || (event_data && event_data.res)) {
|
||||||
|
isHost = true;
|
||||||
|
} else if (
|
||||||
|
type === 'project_update_event' &&
|
||||||
|
event !== 'runner_on_skipped' &&
|
||||||
|
event_data.host
|
||||||
|
) {
|
||||||
|
isHost = true;
|
||||||
|
} else {
|
||||||
|
isHost = false;
|
||||||
|
}
|
||||||
|
return isHost;
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CellMeasurer
|
<CellMeasurer
|
||||||
key={key}
|
key={key}
|
||||||
@@ -161,7 +197,13 @@ class JobOutput extends Component {
|
|||||||
columnIndex={0}
|
columnIndex={0}
|
||||||
>
|
>
|
||||||
{results[index] ? (
|
{results[index] ? (
|
||||||
<JobEvent className="row" style={style} {...results[index]} />
|
<JobEvent
|
||||||
|
isClickable={isHostEvent(results[index])}
|
||||||
|
onJobEventClick={() => this.handleHostEventClick(results[index])}
|
||||||
|
className="row"
|
||||||
|
style={style}
|
||||||
|
{...results[index]}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<JobEventSkeleton
|
<JobEventSkeleton
|
||||||
className="row"
|
className="row"
|
||||||
@@ -242,7 +284,13 @@ class JobOutput extends Component {
|
|||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { job } = this.props;
|
const { job } = this.props;
|
||||||
const { hasContentLoading, contentError, remoteRowCount } = this.state;
|
const {
|
||||||
|
contentError,
|
||||||
|
hasContentLoading,
|
||||||
|
hostEvent,
|
||||||
|
isHostModalOpen,
|
||||||
|
remoteRowCount,
|
||||||
|
} = this.state;
|
||||||
|
|
||||||
if (hasContentLoading) {
|
if (hasContentLoading) {
|
||||||
return <ContentLoading />;
|
return <ContentLoading />;
|
||||||
@@ -254,6 +302,13 @@ class JobOutput extends Component {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<CardBody>
|
<CardBody>
|
||||||
|
{isHostModalOpen && (
|
||||||
|
<HostEventModal
|
||||||
|
onClose={this.handleHostModalClose}
|
||||||
|
isOpen={isHostModalOpen}
|
||||||
|
hostEvent={hostEvent}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<OutputHeader>{job.name}</OutputHeader>
|
<OutputHeader>{job.name}</OutputHeader>
|
||||||
<OutputToolbar>
|
<OutputToolbar>
|
||||||
<MenuControls
|
<MenuControls
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ export default styled.div`
|
|||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background-color: white;
|
background-color: white;
|
||||||
|
cursor: ${props => (props.isClickable ? 'pointer' : 'default')};
|
||||||
}
|
}
|
||||||
|
|
||||||
&:hover div {
|
&:hover div {
|
||||||
|
|||||||
Reference in New Issue
Block a user