From 5babab7af4a32212a5d363786f8860de3417f3bb Mon Sep 17 00:00:00 2001 From: Marliana Lara Date: Fri, 6 Sep 2019 12:43:17 -0400 Subject: [PATCH 1/8] Add host status icon and pull status styles into separate file --- .../components/Sparkline/HostStatusIcon.jsx | 58 +++++++++++++++ .../components/Sparkline/JobStatusIcon.jsx | 64 +++-------------- awx/ui_next/src/components/Sparkline/index.js | 1 + .../Sparkline/shared/StatusIcon.jsx | 72 +++++++++++++++++++ 4 files changed, 140 insertions(+), 55 deletions(-) create mode 100644 awx/ui_next/src/components/Sparkline/HostStatusIcon.jsx create mode 100644 awx/ui_next/src/components/Sparkline/shared/StatusIcon.jsx diff --git a/awx/ui_next/src/components/Sparkline/HostStatusIcon.jsx b/awx/ui_next/src/components/Sparkline/HostStatusIcon.jsx new file mode 100644 index 0000000000..5558016bf2 --- /dev/null +++ b/awx/ui_next/src/components/Sparkline/HostStatusIcon.jsx @@ -0,0 +1,58 @@ +import React from 'react'; +import { string } from 'prop-types'; +import { + ChangedBottom, + ChangedTop, + FailedBottom, + FailedTop, + FinishedJob, + SkippedBottom, + SkippedTop, + SuccessfulBottom, + SuccessfulTop, + UnreachableBottom, + UnreachableTop, +} from './shared/StatusIcon'; + +const HostStatusIcon = ({ status }) => { + return ( +
+ {status === 'changed' && ( + + + + + )} + {status === 'failed' && ( + + + + + )} + {status === 'skipped' && ( + + + + + )} + {status === 'ok' && ( + + + + + )} + {status === 'unreachable' && ( + + + + + )} +
+ ); +}; + +HostStatusIcon.propTypes = { + status: string.isRequired, +}; + +export default HostStatusIcon; diff --git a/awx/ui_next/src/components/Sparkline/JobStatusIcon.jsx b/awx/ui_next/src/components/Sparkline/JobStatusIcon.jsx index 8a462f72ed..0167793fa8 100644 --- a/awx/ui_next/src/components/Sparkline/JobStatusIcon.jsx +++ b/awx/ui_next/src/components/Sparkline/JobStatusIcon.jsx @@ -1,60 +1,14 @@ import React from 'react'; import { string } from 'prop-types'; -import styled, { keyframes } from 'styled-components'; - -const Pulse = keyframes` - from { - -webkit-transform:scale(1); - } - to { - -webkit-transform:scale(0); - } -`; - -const Wrapper = styled.div` - width: 14px; - height: 14px; -`; - -const RunningJob = styled(Wrapper)` - background-color: #5cb85c; - padding-right: 0px; - text-shadow: -1px -1px 0 #ffffff, 1px -1px 0 #ffffff, -1px 1px 0 #ffffff, - 1px 1px 0 #ffffff; - animation: ${Pulse} 1.5s linear infinite alternate; -`; - -const WaitingJob = styled(Wrapper)` - border: 1px solid #d7d7d7; -`; - -const FinishedJob = styled(Wrapper)` - flex: 0 1 auto; - > * { - width: 14px; - height: 7px; - } -`; - -const SuccessfulTop = styled.div` - background-color: #5cb85c; -`; - -const SuccessfulBottom = styled.div` - 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` - background-color: #d9534f; -`; +import { + RunningJob, + WaitingJob, + FinishedJob, + SuccessfulTop, + SuccessfulBottom, + FailedBottom, + FailedTop, +} from './shared/StatusIcon'; const JobStatusIcon = ({ status, ...props }) => { return ( diff --git a/awx/ui_next/src/components/Sparkline/index.js b/awx/ui_next/src/components/Sparkline/index.js index f33b83ae6c..2b299b651e 100644 --- a/awx/ui_next/src/components/Sparkline/index.js +++ b/awx/ui_next/src/components/Sparkline/index.js @@ -1,2 +1,3 @@ export { default as Sparkline } from './Sparkline'; export { default as JobStatusIcon } from './JobStatusIcon'; +export { default as HostStatusIcon } from './HostStatusIcon'; diff --git a/awx/ui_next/src/components/Sparkline/shared/StatusIcon.jsx b/awx/ui_next/src/components/Sparkline/shared/StatusIcon.jsx new file mode 100644 index 0000000000..c02af801b1 --- /dev/null +++ b/awx/ui_next/src/components/Sparkline/shared/StatusIcon.jsx @@ -0,0 +1,72 @@ +import styled, { keyframes } from 'styled-components'; + +const Pulse = keyframes` + from { + -webkit-transform:scale(1); + } + to { + -webkit-transform:scale(0); + } +`; + +const Wrapper = styled.div` + width: 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; +`; + +export const RunningJob = styled(Wrapper)` + background-color: #5cb85c; + padding-right: 0px; + text-shadow: -1px -1px 0 #ffffff, 1px -1px 0 #ffffff, -1px 1px 0 #ffffff, + 1px 1px 0 #ffffff; + animation: ${Pulse} 1.5s linear infinite alternate; +`; + +export const WaitingJob = styled(Wrapper)` + border: 1px solid #d7d7d7; +`; + +export const FinishedJob = styled(Wrapper)` + flex: 0 1 auto; + > * { + width: 14px; + height: 7px; + } +`; + +export const SuccessfulTop = styled.div` + background-color: #5cb85c; +`; +export const SuccessfulBottom = styled(WhiteBottom)``; + +export const FailedTop = styled(WhiteTop)``; +export const FailedBottom = styled.div` + background-color: #d9534f; +`; + +export const UnreachableTop = styled(WhiteTop)``; +export const UnreachableBottom = styled.div` + background-color: #ff0000; +`; + +export const ChangedTop = styled(WhiteTop)``; +export const ChangedBottom = styled.div` + background-color: #ff9900; +`; + +export const SkippedTop = styled(WhiteTop)``; +export const SkippedBottom = styled.div` + background-color: #2dbaba; +`; From 7480baf2569e6a8ec0e67ac29f487f23f113197d Mon Sep 17 00:00:00 2001 From: Marliana Lara Date: Fri, 6 Sep 2019 12:43:53 -0400 Subject: [PATCH 2/8] Add host event modal --- .../screens/Job/JobOutput/HostEventModal.jsx | 218 ++++++++++++++++++ .../src/screens/Job/JobOutput/JobEvent.jsx | 4 + .../src/screens/Job/JobOutput/JobOutput.jsx | 59 ++++- .../Job/JobOutput/shared/JobEventLine.jsx | 1 + 4 files changed, 280 insertions(+), 2 deletions(-) create mode 100644 awx/ui_next/src/screens/Job/JobOutput/HostEventModal.jsx diff --git a/awx/ui_next/src/screens/Job/JobOutput/HostEventModal.jsx b/awx/ui_next/src/screens/Job/JobOutput/HostEventModal.jsx new file mode 100644 index 0000000000..52297e50de --- /dev/null +++ b/awx/ui_next/src/screens/Job/JobOutput/HostEventModal.jsx @@ -0,0 +1,218 @@ +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 { DetailList, Detail } from '@components/DetailList'; +import { HostStatusIcon } 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; + .pf-c-modal-box__body { + overflow-y: hidden; + } + .pf-c-tab-content { + padding: 24px 0; + } +`; + +const HostNameDetailValue = styled.div` + align-items: center; + display: inline-grid; + grid-gap: 10px; + grid-template-columns: min-content auto; +`; + +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; + } +`; + +function HostEventModal({ handleClose, hostEvent, isOpen, i18n }) { + const [hostStatus, setHostStatus] = useState(null); + const [activeTabKey, setActiveTabKey] = useState(0); + + useEffect(() => { + processEventStatus(hostEvent); + }, []); + + const handleTabClick = (event, tabIndex) => { + setActiveTabKey(tabIndex); + }; + + function 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'; + } + // catch the 'changed' case before 'ok', because both can be true + if (event.changed) { + status = 'changed'; + } + if ( + event.event === 'runner_on_ok' || + event.event === 'runner_on_async_ok' || + event.event === 'runner_item_on_ok' + ) { + status = 'ok'; + } + if (event.event === 'runner_on_skipped') { + status = 'skipped'; + } + setHostStatus(status); + } + + function processStdOutValue() { + const { res } = hostEvent.event_data; + let stdOut; + if (taskAction === 'debug' && res.result && res.result.stdout) { + stdOut = processCodeMirrorValue(res.result.stdout); + } else if ( + taskAction === 'yum' && + res.results && + Array.isArray(res.results) + ) { + stdOut = processCodeMirrorValue(res.results[0]); + } else { + stdOut = processCodeMirrorValue(res.stdout); + } + return stdOut; + } + + 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 taskAction = hostEvent.event_data.task_action; + const JSONObj = processCodeMirrorValue(hostEvent.event_data.res); + const StdErr = processCodeMirrorValue(hostEvent.event_data.res.stderr); + const StdOut = processStdOutValue(); + + return ( + + {i18n._(t`Close`)} + , + ]} + > + + + + + {hostStatus && } + {hostEvent.host_name} + + } + /> + + + + + + + + {activeTabKey === 1 && JSONObj ? ( + {}} + rows={20} + hasErrors={false} + /> + ) : ( + + )} + + + {activeTabKey === 2 && StdOut ? ( + {}} + rows={20} + hasErrors={false} + /> + ) : ( + + )} + + + {activeTabKey === 3 && StdErr ? ( + {}} + value={StdErr} + hasErrors={false} + rows={20} + /> + ) : ( + + )} + + + + ); +} + +export default withI18n()(HostEventModal); diff --git a/awx/ui_next/src/screens/Job/JobOutput/JobEvent.jsx b/awx/ui_next/src/screens/Job/JobOutput/JobEvent.jsx index 4e41d50a90..769ccf42a4 100644 --- a/awx/ui_next/src/screens/Job/JobOutput/JobEvent.jsx +++ b/awx/ui_next/src/screens/Job/JobOutput/JobEvent.jsx @@ -77,6 +77,8 @@ function JobEvent({ counter, created, event, + isClickable, + onJobEventClick, stdout, start_line, style, @@ -88,8 +90,10 @@ function JobEvent({ ({ lineNumber, html }) => lineNumber >= 0 && ( {lineNumber} diff --git a/awx/ui_next/src/screens/Job/JobOutput/JobOutput.jsx b/awx/ui_next/src/screens/Job/JobOutput/JobOutput.jsx index 18508c1372..d5c928a99c 100644 --- a/awx/ui_next/src/screens/Job/JobOutput/JobOutput.jsx +++ b/awx/ui_next/src/screens/Job/JobOutput/JobOutput.jsx @@ -16,6 +16,7 @@ import ContentLoading from '@components/ContentLoading'; import JobEvent from './JobEvent'; import JobEventSkeleton from './JobEventSkeleton'; import MenuControls from './MenuControls'; +import HostEventModal from './HostEventModal'; const OutputHeader = styled.div` font-weight: var(--pf-global--FontWeight--bold); @@ -59,6 +60,8 @@ class JobOutput extends Component { results: {}, currentlyLoading: [], remoteRowCount: 0, + isHostModalOpen: false, + hostEvent: {}, }; this.cache = new CellMeasurerCache({ @@ -69,6 +72,8 @@ class JobOutput extends Component { this._isMounted = false; this.loadJobEvents = this.loadJobEvents.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.handleScrollLast = this.handleScrollLast.bind(this); this.handleScrollNext = this.handleScrollNext.bind(this); @@ -150,8 +155,39 @@ class JobOutput extends Component { return currentlyLoading.includes(index); } + handleHostEventClick(hostEvent) { + this.setState({ + isHostModalOpen: true, + hostEvent, + }); + } + + handleHostModalClose() { + this.setState({ + isHostModalOpen: false, + }); + } + rowRenderer({ index, parent, key, style }) { 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 ( {results[index] ? ( - + this.handleHostEventClick(results[index])} + className="row" + style={style} + {...results[index]} + /> ) : ( ; @@ -254,6 +302,13 @@ class JobOutput extends Component { return ( + {isHostModalOpen && ( + + )} {job.name} (props.isClickable ? 'pointer' : 'default')}; } &:hover div { From a79de2b4ed1a9a641528cd7ab477a1f50a340965 Mon Sep 17 00:00:00 2001 From: Marliana Lara Date: Fri, 6 Sep 2019 12:44:11 -0400 Subject: [PATCH 3/8] Fix existing test failures --- .../src/components/Sparkline/JobStatusIcon.test.jsx | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/awx/ui_next/src/components/Sparkline/JobStatusIcon.test.jsx b/awx/ui_next/src/components/Sparkline/JobStatusIcon.test.jsx index 204d305394..86485223ab 100644 --- a/awx/ui_next/src/components/Sparkline/JobStatusIcon.test.jsx +++ b/awx/ui_next/src/components/Sparkline/JobStatusIcon.test.jsx @@ -6,23 +6,23 @@ describe('JobStatusIcon', () => { test('renders the successful job', () => { const wrapper = mount(); expect(wrapper).toHaveLength(1); - expect(wrapper.find('JobStatusIcon__SuccessfulTop')).toHaveLength(1); - expect(wrapper.find('JobStatusIcon__SuccessfulBottom')).toHaveLength(1); + expect(wrapper.find('StatusIcon__SuccessfulTop')).toHaveLength(1); + expect(wrapper.find('StatusIcon__SuccessfulBottom')).toHaveLength(1); }); test('renders running job', () => { const wrapper = mount(); expect(wrapper).toHaveLength(1); - expect(wrapper.find('JobStatusIcon__RunningJob')).toHaveLength(1); + expect(wrapper.find('StatusIcon__RunningJob')).toHaveLength(1); }); test('renders waiting job', () => { const wrapper = mount(); expect(wrapper).toHaveLength(1); - expect(wrapper.find('JobStatusIcon__WaitingJob')).toHaveLength(1); + expect(wrapper.find('StatusIcon__WaitingJob')).toHaveLength(1); }); test('renders failed job', () => { const wrapper = mount(); expect(wrapper).toHaveLength(1); - expect(wrapper.find('JobStatusIcon__FailedTop')).toHaveLength(1); - expect(wrapper.find('JobStatusIcon__FailedBottom')).toHaveLength(1); + expect(wrapper.find('StatusIcon__FailedTop')).toHaveLength(1); + expect(wrapper.find('StatusIcon__FailedBottom')).toHaveLength(1); }); }); From 25aa9bc43e2bc4ad875ebfca1beeb0409073daa8 Mon Sep 17 00:00:00 2001 From: Marliana Lara Date: Fri, 6 Sep 2019 12:52:44 -0400 Subject: [PATCH 4/8] Change empty state text --- awx/ui_next/src/screens/Job/JobOutput/HostEventModal.jsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/awx/ui_next/src/screens/Job/JobOutput/HostEventModal.jsx b/awx/ui_next/src/screens/Job/JobOutput/HostEventModal.jsx index 52297e50de..95c92863c7 100644 --- a/awx/ui_next/src/screens/Job/JobOutput/HostEventModal.jsx +++ b/awx/ui_next/src/screens/Job/JobOutput/HostEventModal.jsx @@ -179,7 +179,7 @@ function HostEventModal({ handleClose, hostEvent, isOpen, i18n }) { hasErrors={false} /> ) : ( - + )} @@ -193,7 +193,7 @@ function HostEventModal({ handleClose, hostEvent, isOpen, i18n }) { hasErrors={false} /> ) : ( - + )} @@ -207,7 +207,7 @@ function HostEventModal({ handleClose, hostEvent, isOpen, i18n }) { rows={20} /> ) : ( - + )} From 871f2cf9c549dfc10457fae6950796002c40be9e Mon Sep 17 00:00:00 2001 From: Marliana Lara Date: Tue, 10 Sep 2019 16:29:25 -0400 Subject: [PATCH 5/8] Add host event modal tests and code cleanup --- .../Sparkline/HostStatusIcon.test.jsx | 36 +++ .../screens/Job/JobOutput/HostEventModal.jsx | 196 +++++++----- .../Job/JobOutput/HostEventModal.test.jsx | 297 ++++++++++++++++++ .../src/screens/Job/JobOutput/JobOutput.jsx | 2 +- 4 files changed, 449 insertions(+), 82 deletions(-) create mode 100644 awx/ui_next/src/components/Sparkline/HostStatusIcon.test.jsx create mode 100644 awx/ui_next/src/screens/Job/JobOutput/HostEventModal.test.jsx diff --git a/awx/ui_next/src/components/Sparkline/HostStatusIcon.test.jsx b/awx/ui_next/src/components/Sparkline/HostStatusIcon.test.jsx new file mode 100644 index 0000000000..69c2070582 --- /dev/null +++ b/awx/ui_next/src/components/Sparkline/HostStatusIcon.test.jsx @@ -0,0 +1,36 @@ +import React from 'react'; +import { mount } from 'enzyme'; +import HostStatusIcon from './HostStatusIcon'; + +describe('HostStatusIcon', () => { + test('renders the "ok" host status', () => { + const wrapper = mount(); + 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(); + 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(); + 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(); + 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(); + expect(wrapper).toHaveLength(1); + expect(wrapper.find('StatusIcon__UnreachableTop')).toHaveLength(1); + expect(wrapper.find('StatusIcon__UnreachableBottom')).toHaveLength(1); + }); +}); diff --git a/awx/ui_next/src/screens/Job/JobOutput/HostEventModal.jsx b/awx/ui_next/src/screens/Job/JobOutput/HostEventModal.jsx index 95c92863c7..7c826518a1 100644 --- a/awx/ui_next/src/screens/Job/JobOutput/HostEventModal.jsx +++ b/awx/ui_next/src/screens/Job/JobOutput/HostEventModal.jsx @@ -7,6 +7,7 @@ import { } 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 { HostStatusIcon } from '@components/Sparkline'; import { withI18n } from '@lingui/react'; @@ -28,9 +29,10 @@ const Modal = styled(PFModal)` const HostNameDetailValue = styled.div` align-items: center; - display: inline-grid; - grid-gap: 10px; - grid-template-columns: min-content auto; + display: inline-flex; + > div { + margin-right: 10px; + } `; const Tabs = styled(PFTabs)` @@ -58,100 +60,107 @@ const Tabs = styled(PFTabs)` } `; -function HostEventModal({ handleClose, hostEvent, isOpen, i18n }) { +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'; + } + // catch the 'changed' case after 'ok', because both can be true + 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(() => { - processEventStatus(hostEvent); + setHostStatus(processEventStatus(hostEvent)); }, []); const handleTabClick = (event, tabIndex) => { setActiveTabKey(tabIndex); }; - function 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'; - } - // catch the 'changed' case before 'ok', because both can be true - if (event.changed) { - status = 'changed'; - } - if ( - event.event === 'runner_on_ok' || - event.event === 'runner_on_async_ok' || - event.event === 'runner_item_on_ok' - ) { - status = 'ok'; - } - if (event.event === 'runner_on_skipped') { - status = 'skipped'; - } - setHostStatus(status); - } - - function processStdOutValue() { - const { res } = hostEvent.event_data; - let stdOut; - if (taskAction === 'debug' && res.result && res.result.stdout) { - stdOut = processCodeMirrorValue(res.result.stdout); - } else if ( - taskAction === 'yum' && - res.results && - Array.isArray(res.results) - ) { - stdOut = processCodeMirrorValue(res.results[0]); - } else { - stdOut = processCodeMirrorValue(res.stdout); - } - return stdOut; - } - - 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 taskAction = hostEvent.event_data.task_action; - const JSONObj = processCodeMirrorValue(hostEvent.event_data.res); - const StdErr = processCodeMirrorValue(hostEvent.event_data.res.stderr); - const StdOut = processStdOutValue(); + const jsonObj = processCodeMirrorValue(hostEvent.event_data.res); + const stdErr = processCodeMirrorValue(hostEvent.event_data.res.stderr); + const stdOut = processCodeMirrorValue(processStdOutValue(hostEvent)); return ( + , ]} > - - + + - {hostStatus && } + {hostStatus ? : null} {hostEvent.host_name} } @@ -160,7 +169,9 @@ function HostEventModal({ handleClose, hostEvent, isOpen, i18n }) { - - {activeTabKey === 1 && JSONObj ? ( + + {activeTabKey === 1 && jsonObj ? ( {}} rows={20} hasErrors={false} @@ -182,12 +197,16 @@ function HostEventModal({ handleClose, hostEvent, isOpen, i18n }) { )} - - {activeTabKey === 2 && StdOut ? ( + + {activeTabKey === 2 && stdOut ? ( {}} rows={20} hasErrors={false} @@ -196,13 +215,17 @@ function HostEventModal({ handleClose, hostEvent, isOpen, i18n }) { )} - - {activeTabKey === 3 && StdErr ? ( + + {activeTabKey === 3 && stdErr ? ( {}} - value={StdErr} + value={stdErr} hasErrors={false} rows={20} /> @@ -216,3 +239,14 @@ function HostEventModal({ handleClose, hostEvent, isOpen, i18n }) { } export default withI18n()(HostEventModal); + +HostEventModal.propTypes = { + onClose: PropTypes.func.isRequired, + hostEvent: PropTypes.shape({}), + isOpen: PropTypes.bool, +}; + +HostEventModal.defaultProps = { + hostEvent: null, + isOpen: false, +}; diff --git a/awx/ui_next/src/screens/Job/JobOutput/HostEventModal.test.jsx b/awx/ui_next/src/screens/Job/JobOutput/HostEventModal.test.jsx new file mode 100644 index 0000000000..716562ddb6 --- /dev/null +++ b/awx/ui_next/src/screens/Job/JobOutput/HostEventModal.test.jsx @@ -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: "changed: [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"]}" + `, + 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( + {}} /> + ); + expect(wrapper).toHaveLength(1); + }); + + test('should render all tabs', () => { + const wrapper = mountWithContexts( + {}} isOpen /> + ); + + /* eslint-disable react/button-has-type */ + expect( + wrapper + .find('Tabs') + .containsAllMatchingElements([ + , + , + , + , + ]) + ).toEqual(true); + }); + + test('should show details tab content on mount', () => { + const wrapper = mountWithContexts( + {}} 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( + {}} + isOpen + /> + ); + const icon = wrapper.find('HostStatusIcon'); + 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( + {}} isOpen /> + ); + + const icon = wrapper.find('HostStatusIcon'); + 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( + {}} + isOpen + /> + ); + + const icon = wrapper.find('HostStatusIcon'); + 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( + {}} + isOpen + /> + ); + + const icon = wrapper.find('HostStatusIcon'); + 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( + {}} 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( + {}} 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( + {}} 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( + + ); + 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( + {}} 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( + {}} 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'); + }); +}); diff --git a/awx/ui_next/src/screens/Job/JobOutput/JobOutput.jsx b/awx/ui_next/src/screens/Job/JobOutput/JobOutput.jsx index d5c928a99c..bde17d7b0d 100644 --- a/awx/ui_next/src/screens/Job/JobOutput/JobOutput.jsx +++ b/awx/ui_next/src/screens/Job/JobOutput/JobOutput.jsx @@ -304,7 +304,7 @@ class JobOutput extends Component { {isHostModalOpen && ( From 2527a78874f6d22a9dd99e69596f73d93d46d705 Mon Sep 17 00:00:00 2001 From: Marliana Lara Date: Tue, 10 Sep 2019 17:06:53 -0400 Subject: [PATCH 6/8] Combine host and job status icons into one generic status icon --- .../components/Sparkline/HostStatusIcon.jsx | 58 --------- .../Sparkline/HostStatusIcon.test.jsx | 36 ------ .../components/Sparkline/JobStatusIcon.jsx | 40 ------ .../Sparkline/JobStatusIcon.test.jsx | 28 ---- .../src/components/Sparkline/Sparkline.jsx | 4 +- .../components/Sparkline/Sparkline.test.jsx | 2 +- .../src/components/Sparkline/StatusIcon.jsx | 121 ++++++++++++++++++ .../components/Sparkline/StatusIcon.test.jsx | 59 +++++++++ awx/ui_next/src/components/Sparkline/index.js | 3 +- .../Sparkline/shared/StatusIcon.jsx | 72 ----------- .../screens/Job/JobOutput/HostEventModal.jsx | 6 +- .../Job/JobOutput/HostEventModal.test.jsx | 8 +- 12 files changed, 191 insertions(+), 246 deletions(-) delete mode 100644 awx/ui_next/src/components/Sparkline/HostStatusIcon.jsx delete mode 100644 awx/ui_next/src/components/Sparkline/HostStatusIcon.test.jsx delete mode 100644 awx/ui_next/src/components/Sparkline/JobStatusIcon.jsx delete mode 100644 awx/ui_next/src/components/Sparkline/JobStatusIcon.test.jsx create mode 100644 awx/ui_next/src/components/Sparkline/StatusIcon.jsx create mode 100644 awx/ui_next/src/components/Sparkline/StatusIcon.test.jsx delete mode 100644 awx/ui_next/src/components/Sparkline/shared/StatusIcon.jsx diff --git a/awx/ui_next/src/components/Sparkline/HostStatusIcon.jsx b/awx/ui_next/src/components/Sparkline/HostStatusIcon.jsx deleted file mode 100644 index 5558016bf2..0000000000 --- a/awx/ui_next/src/components/Sparkline/HostStatusIcon.jsx +++ /dev/null @@ -1,58 +0,0 @@ -import React from 'react'; -import { string } from 'prop-types'; -import { - ChangedBottom, - ChangedTop, - FailedBottom, - FailedTop, - FinishedJob, - SkippedBottom, - SkippedTop, - SuccessfulBottom, - SuccessfulTop, - UnreachableBottom, - UnreachableTop, -} from './shared/StatusIcon'; - -const HostStatusIcon = ({ status }) => { - return ( -
- {status === 'changed' && ( - - - - - )} - {status === 'failed' && ( - - - - - )} - {status === 'skipped' && ( - - - - - )} - {status === 'ok' && ( - - - - - )} - {status === 'unreachable' && ( - - - - - )} -
- ); -}; - -HostStatusIcon.propTypes = { - status: string.isRequired, -}; - -export default HostStatusIcon; diff --git a/awx/ui_next/src/components/Sparkline/HostStatusIcon.test.jsx b/awx/ui_next/src/components/Sparkline/HostStatusIcon.test.jsx deleted file mode 100644 index 69c2070582..0000000000 --- a/awx/ui_next/src/components/Sparkline/HostStatusIcon.test.jsx +++ /dev/null @@ -1,36 +0,0 @@ -import React from 'react'; -import { mount } from 'enzyme'; -import HostStatusIcon from './HostStatusIcon'; - -describe('HostStatusIcon', () => { - test('renders the "ok" host status', () => { - const wrapper = mount(); - 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(); - 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(); - 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(); - 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(); - expect(wrapper).toHaveLength(1); - expect(wrapper.find('StatusIcon__UnreachableTop')).toHaveLength(1); - expect(wrapper.find('StatusIcon__UnreachableBottom')).toHaveLength(1); - }); -}); diff --git a/awx/ui_next/src/components/Sparkline/JobStatusIcon.jsx b/awx/ui_next/src/components/Sparkline/JobStatusIcon.jsx deleted file mode 100644 index 0167793fa8..0000000000 --- a/awx/ui_next/src/components/Sparkline/JobStatusIcon.jsx +++ /dev/null @@ -1,40 +0,0 @@ -import React from 'react'; -import { string } from 'prop-types'; -import { - RunningJob, - WaitingJob, - FinishedJob, - SuccessfulTop, - SuccessfulBottom, - FailedBottom, - FailedTop, -} from './shared/StatusIcon'; - -const JobStatusIcon = ({ status, ...props }) => { - return ( -
- {status === 'running' && } - {(status === 'new' || status === 'pending' || status === 'waiting') && ( - - )} - {(status === 'failed' || status === 'error' || status === 'canceled') && ( - - - - - )} - {status === 'successful' && ( - - - - - )} -
- ); -}; - -JobStatusIcon.propTypes = { - status: string.isRequired, -}; - -export default JobStatusIcon; diff --git a/awx/ui_next/src/components/Sparkline/JobStatusIcon.test.jsx b/awx/ui_next/src/components/Sparkline/JobStatusIcon.test.jsx deleted file mode 100644 index 86485223ab..0000000000 --- a/awx/ui_next/src/components/Sparkline/JobStatusIcon.test.jsx +++ /dev/null @@ -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(); - expect(wrapper).toHaveLength(1); - expect(wrapper.find('StatusIcon__SuccessfulTop')).toHaveLength(1); - expect(wrapper.find('StatusIcon__SuccessfulBottom')).toHaveLength(1); - }); - test('renders running job', () => { - const wrapper = mount(); - expect(wrapper).toHaveLength(1); - expect(wrapper.find('StatusIcon__RunningJob')).toHaveLength(1); - }); - test('renders waiting job', () => { - const wrapper = mount(); - expect(wrapper).toHaveLength(1); - expect(wrapper.find('StatusIcon__WaitingJob')).toHaveLength(1); - }); - test('renders failed job', () => { - const wrapper = mount(); - expect(wrapper).toHaveLength(1); - expect(wrapper.find('StatusIcon__FailedTop')).toHaveLength(1); - expect(wrapper.find('StatusIcon__FailedBottom')).toHaveLength(1); - }); -}); diff --git a/awx/ui_next/src/components/Sparkline/Sparkline.jsx b/awx/ui_next/src/components/Sparkline/Sparkline.jsx index 5291eeca85..57029ce73a 100644 --- a/awx/ui_next/src/components/Sparkline/Sparkline.jsx +++ b/awx/ui_next/src/components/Sparkline/Sparkline.jsx @@ -2,7 +2,7 @@ import React, { Fragment } from 'react'; import { arrayOf, object } from 'prop-types'; import { withI18n } from '@lingui/react'; 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 styled from 'styled-components'; import { t } from '@lingui/macro'; @@ -34,7 +34,7 @@ const Sparkline = ({ i18n, jobs }) => { return jobs.map(job => ( - + )); diff --git a/awx/ui_next/src/components/Sparkline/Sparkline.test.jsx b/awx/ui_next/src/components/Sparkline/Sparkline.test.jsx index e39003a841..0d4d5579e9 100644 --- a/awx/ui_next/src/components/Sparkline/Sparkline.test.jsx +++ b/awx/ui_next/src/components/Sparkline/Sparkline.test.jsx @@ -23,7 +23,7 @@ describe('Sparkline', () => { }, ]; const wrapper = mountWithContexts(); - expect(wrapper.find('JobStatusIcon')).toHaveLength(2); + expect(wrapper.find('StatusIcon')).toHaveLength(2); expect(wrapper.find('Tooltip')).toHaveLength(2); expect(wrapper.find('Link')).toHaveLength(2); }); diff --git a/awx/ui_next/src/components/Sparkline/StatusIcon.jsx b/awx/ui_next/src/components/Sparkline/StatusIcon.jsx new file mode 100644 index 0000000000..5f048f5cb5 --- /dev/null +++ b/awx/ui_next/src/components/Sparkline/StatusIcon.jsx @@ -0,0 +1,121 @@ +import React from 'react'; +import { string } from 'prop-types'; +import styled, { keyframes } from 'styled-components'; + +const Pulse = keyframes` + from { + -webkit-transform:scale(1); + } + to { + -webkit-transform:scale(0); + } +`; + +const Wrapper = styled.div` + width: 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)` + background-color: #5cb85c; + padding-right: 0px; + text-shadow: -1px -1px 0 #ffffff, 1px -1px 0 #ffffff, -1px 1px 0 #ffffff, + 1px 1px 0 #ffffff; + animation: ${Pulse} 1.5s linear infinite alternate; +`; + +const WaitingJob = styled(Wrapper)` + border: 1px solid #d7d7d7; +`; + +const FinishedJob = styled(Wrapper)` + flex: 0 1 auto; + > * { + width: 14px; + height: 7px; + } +`; + +const SuccessfulTop = styled.div` + background-color: #5cb85c; +`; +const SuccessfulBottom = styled(WhiteBottom)``; + +const FailedTop = styled(WhiteTop)``; +const FailedBottom = styled.div` + background-color: #d9534f; +`; + +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 ( +
+ {status === 'running' && } + {(status === 'new' || status === 'pending' || status === 'waiting') && ( + + )} + {(status === 'failed' || status === 'error' || status === 'canceled') && ( + + + + + )} + {(status === 'successful' || status === 'ok') && ( + + + + + )} + {status === 'changed' && ( + + + + + )} + {status === 'skipped' && ( + + + + + )} + {status === 'unreachable' && ( + + + + + )} +
+ ); +}; + +StatusIcon.propTypes = { + status: string.isRequired, +}; + +export default StatusIcon; diff --git a/awx/ui_next/src/components/Sparkline/StatusIcon.test.jsx b/awx/ui_next/src/components/Sparkline/StatusIcon.test.jsx new file mode 100644 index 0000000000..fd47a309c8 --- /dev/null +++ b/awx/ui_next/src/components/Sparkline/StatusIcon.test.jsx @@ -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(); + 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(); + expect(wrapper).toHaveLength(1); + expect(wrapper.find('StatusIcon__RunningJob')).toHaveLength(1); + }); + test('renders waiting status', () => { + const wrapper = mount(); + expect(wrapper).toHaveLength(1); + expect(wrapper.find('StatusIcon__WaitingJob')).toHaveLength(1); + }); + test('renders failed status', () => { + const wrapper = mount(); + 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(); + 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(); + 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(); + 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(); + 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(); + expect(wrapper).toHaveLength(1); + expect(wrapper.find('StatusIcon__UnreachableTop')).toHaveLength(1); + expect(wrapper.find('StatusIcon__UnreachableBottom')).toHaveLength(1); + }); +}); diff --git a/awx/ui_next/src/components/Sparkline/index.js b/awx/ui_next/src/components/Sparkline/index.js index 2b299b651e..f7b30c0d98 100644 --- a/awx/ui_next/src/components/Sparkline/index.js +++ b/awx/ui_next/src/components/Sparkline/index.js @@ -1,3 +1,2 @@ export { default as Sparkline } from './Sparkline'; -export { default as JobStatusIcon } from './JobStatusIcon'; -export { default as HostStatusIcon } from './HostStatusIcon'; +export { default as StatusIcon } from './StatusIcon'; diff --git a/awx/ui_next/src/components/Sparkline/shared/StatusIcon.jsx b/awx/ui_next/src/components/Sparkline/shared/StatusIcon.jsx deleted file mode 100644 index c02af801b1..0000000000 --- a/awx/ui_next/src/components/Sparkline/shared/StatusIcon.jsx +++ /dev/null @@ -1,72 +0,0 @@ -import styled, { keyframes } from 'styled-components'; - -const Pulse = keyframes` - from { - -webkit-transform:scale(1); - } - to { - -webkit-transform:scale(0); - } -`; - -const Wrapper = styled.div` - width: 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; -`; - -export const RunningJob = styled(Wrapper)` - background-color: #5cb85c; - padding-right: 0px; - text-shadow: -1px -1px 0 #ffffff, 1px -1px 0 #ffffff, -1px 1px 0 #ffffff, - 1px 1px 0 #ffffff; - animation: ${Pulse} 1.5s linear infinite alternate; -`; - -export const WaitingJob = styled(Wrapper)` - border: 1px solid #d7d7d7; -`; - -export const FinishedJob = styled(Wrapper)` - flex: 0 1 auto; - > * { - width: 14px; - height: 7px; - } -`; - -export const SuccessfulTop = styled.div` - background-color: #5cb85c; -`; -export const SuccessfulBottom = styled(WhiteBottom)``; - -export const FailedTop = styled(WhiteTop)``; -export const FailedBottom = styled.div` - background-color: #d9534f; -`; - -export const UnreachableTop = styled(WhiteTop)``; -export const UnreachableBottom = styled.div` - background-color: #ff0000; -`; - -export const ChangedTop = styled(WhiteTop)``; -export const ChangedBottom = styled.div` - background-color: #ff9900; -`; - -export const SkippedTop = styled(WhiteTop)``; -export const SkippedBottom = styled.div` - background-color: #2dbaba; -`; diff --git a/awx/ui_next/src/screens/Job/JobOutput/HostEventModal.jsx b/awx/ui_next/src/screens/Job/JobOutput/HostEventModal.jsx index 7c826518a1..79aa92a560 100644 --- a/awx/ui_next/src/screens/Job/JobOutput/HostEventModal.jsx +++ b/awx/ui_next/src/screens/Job/JobOutput/HostEventModal.jsx @@ -9,7 +9,7 @@ import CodeMirrorInput from '@components/CodeMirrorInput'; import ContentEmpty from '@components/ContentEmpty'; import PropTypes from 'prop-types'; import { DetailList, Detail } from '@components/DetailList'; -import { HostStatusIcon } from '@components/Sparkline'; +import { StatusIcon } from '@components/Sparkline'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; import styled from 'styled-components'; @@ -76,7 +76,7 @@ const processEventStatus = event => { ) { status = 'ok'; } - // catch the 'changed' case after 'ok', because both can be true + // if 'ok' and 'changed' are both true, show 'changed' if (event.changed) { status = 'changed'; } @@ -160,7 +160,7 @@ function HostEventModal({ onClose, hostEvent = {}, isOpen = false, i18n }) { label={i18n._(t`Host Name`)} value={ - {hostStatus ? : null} + {hostStatus ? : null} {hostEvent.host_name} } diff --git a/awx/ui_next/src/screens/Job/JobOutput/HostEventModal.test.jsx b/awx/ui_next/src/screens/Job/JobOutput/HostEventModal.test.jsx index 716562ddb6..c67640d7ce 100644 --- a/awx/ui_next/src/screens/Job/JobOutput/HostEventModal.test.jsx +++ b/awx/ui_next/src/screens/Job/JobOutput/HostEventModal.test.jsx @@ -128,7 +128,7 @@ describe('HostEventModal', () => { isOpen /> ); - const icon = wrapper.find('HostStatusIcon'); + 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); @@ -140,7 +140,7 @@ describe('HostEventModal', () => { {}} isOpen /> ); - const icon = wrapper.find('HostStatusIcon'); + 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); @@ -160,7 +160,7 @@ describe('HostEventModal', () => { /> ); - const icon = wrapper.find('HostStatusIcon'); + 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); @@ -181,7 +181,7 @@ describe('HostEventModal', () => { /> ); - const icon = wrapper.find('HostStatusIcon'); + 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); From e53c9793441b4eb80525d9f1bf16e0ebe0d7aa74 Mon Sep 17 00:00:00 2001 From: Marliana Lara Date: Tue, 10 Sep 2019 17:09:25 -0400 Subject: [PATCH 7/8] Show changed status when the status is both ok and changed --- .../features/output/host-event/host-event.service.js | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/awx/ui/client/features/output/host-event/host-event.service.js b/awx/ui/client/features/output/host-event/host-event.service.js index 1e0588b329..4163cb2e95 100644 --- a/awx/ui/client/features/output/host-event/host-event.service.js +++ b/awx/ui/client/features/output/host-event/host-event.service.js @@ -66,15 +66,15 @@ function HostEventService ( obj.class = 'HostEvent-status--failed'; obj.status = 'failed'; } - // catch the changed case before ok, because both can be true + if (event.event === 'runner_on_ok' || event.event === 'runner_on_async_ok') { + obj.class = 'HostEvent-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_ok' || event.event === 'runner_on_async_ok') { - obj.class = 'HostEvent-status--ok'; - obj.status = 'ok'; - } if (event.event === 'runner_on_skipped') { obj.class = 'HostEvent-status--skipped'; obj.status = 'skipped'; From ad2e58cd4396397b748278c4507cb4e5fc94ebaa Mon Sep 17 00:00:00 2001 From: Marliana Lara Date: Wed, 11 Sep 2019 11:03:00 -0400 Subject: [PATCH 8/8] Align the top of the modal to a fixed distance from the top of the browser --- .../client/features/output/host-event/host-event.service.js | 4 ++-- awx/ui_next/src/screens/Job/JobOutput/HostEventModal.jsx | 2 ++ 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/awx/ui/client/features/output/host-event/host-event.service.js b/awx/ui/client/features/output/host-event/host-event.service.js index 4163cb2e95..4709c1055f 100644 --- a/awx/ui/client/features/output/host-event/host-event.service.js +++ b/awx/ui/client/features/output/host-event/host-event.service.js @@ -67,8 +67,8 @@ function HostEventService ( obj.status = 'failed'; } if (event.event === 'runner_on_ok' || event.event === 'runner_on_async_ok') { - obj.class = 'HostEvent-status--ok'; - obj.status = 'ok'; + obj.class = 'HostEvent-status--ok'; + obj.status = 'ok'; } // if both 'changed' and 'ok' are true, show 'changed' status if (event.changed) { diff --git a/awx/ui_next/src/screens/Job/JobOutput/HostEventModal.jsx b/awx/ui_next/src/screens/Job/JobOutput/HostEventModal.jsx index 79aa92a560..08e3866983 100644 --- a/awx/ui_next/src/screens/Job/JobOutput/HostEventModal.jsx +++ b/awx/ui_next/src/screens/Job/JobOutput/HostEventModal.jsx @@ -19,6 +19,8 @@ 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; }