Repository: aurora Updated Branches: refs/heads/master 2df250e3e -> 4a1fba3c8
Implement Update and Updates pages in React. Reviewed at https://reviews.apache.org/r/62763/ Project: http://git-wip-us.apache.org/repos/asf/aurora/repo Commit: http://git-wip-us.apache.org/repos/asf/aurora/commit/4a1fba3c Tree: http://git-wip-us.apache.org/repos/asf/aurora/tree/4a1fba3c Diff: http://git-wip-us.apache.org/repos/asf/aurora/diff/4a1fba3c Branch: refs/heads/master Commit: 4a1fba3c8ae1dd9a302590f3fdfcc8852636c0bf Parents: 2df250e Author: David McLaughlin <[email protected]> Authored: Tue Oct 10 10:14:50 2017 -0700 Committer: David McLaughlin <[email protected]> Committed: Tue Oct 10 10:14:50 2017 -0700 ---------------------------------------------------------------------- ui/.eslintrc | 10 +- ui/package.json | 6 +- ui/src/main/js/components/InstanceViz.js | 17 ++ ui/src/main/js/components/Layout.js | 18 +- ui/src/main/js/components/Pagination.js | 10 +- ui/src/main/js/components/TaskConfig.js | 5 + ui/src/main/js/components/Time.js | 6 + ui/src/main/js/components/UpdateConfig.js | 12 + ui/src/main/js/components/UpdateDetails.js | 28 ++ .../main/js/components/UpdateInstanceEvents.js | 101 +++++++ .../main/js/components/UpdateInstanceSummary.js | 22 ++ ui/src/main/js/components/UpdateList.js | 47 +++ ui/src/main/js/components/UpdateSettings.js | 30 ++ ui/src/main/js/components/UpdateStateMachine.js | 21 ++ ui/src/main/js/components/UpdateStatus.js | 22 ++ ui/src/main/js/components/UpdateTime.js | 33 +++ ui/src/main/js/components/UpdateTitle.js | 26 ++ .../js/components/__tests__/InstanceViz-test.js | 41 +++ .../js/components/__tests__/Pagination-test.js | 25 ++ .../__tests__/UpdateInstanceEvents-test.js | 40 +++ .../js/components/__tests__/UpdateList-test.js | 17 ++ .../components/__tests__/UpdateStatus-test.js | 23 ++ ui/src/main/js/index.js | 9 +- ui/src/main/js/pages/Update.js | 54 ++++ ui/src/main/js/pages/Updates.js | 66 +++++ ui/src/main/js/pages/__tests__/Update-test.js | 56 ++++ ui/src/main/js/pages/__tests__/Updates-test.js | 33 +++ ui/src/main/js/test-utils/UpdateBuilders.js | 86 ++++++ ui/src/main/js/utils/Common.js | 16 + ui/src/main/js/utils/Thrift.js | 43 ++- ui/src/main/js/utils/Update.js | 163 +++++++++++ ui/src/main/js/utils/__tests__/Update-test.js | 291 +++++++++++++++++++ ui/src/main/sass/app.scss | 5 +- ui/src/main/sass/components/_instance-viz.scss | 78 +++++ ui/src/main/sass/components/_layout.scss | 85 ++++++ ui/src/main/sass/components/_update-list.scss | 38 +++ ui/src/main/sass/components/_update-page.scss | 151 ++++++++++ ui/test-setup.js | 37 ++- 38 files changed, 1759 insertions(+), 12 deletions(-) ---------------------------------------------------------------------- http://git-wip-us.apache.org/repos/asf/aurora/blob/4a1fba3c/ui/.eslintrc ---------------------------------------------------------------------- diff --git a/ui/.eslintrc b/ui/.eslintrc index 84a6d37..f7ac075 100644 --- a/ui/.eslintrc +++ b/ui/.eslintrc @@ -10,10 +10,16 @@ }, "globals": { "ACTIVE_STATES": true, - "Thrift": true, + "ACTIVE_JOB_UPDATE_STATES": true, + "JobKey": true, + "JobUpdateAction": true, + "JobUpdateKey": true, + "JobUpdateQuery": true, + "JobUpdateStatus": true, "ReadOnlySchedulerClient": true, "ScheduleStatus": true, - "TaskQuery": true + "TaskQuery": true, + "Thrift": true }, "plugins": [ "chai-friendly" http://git-wip-us.apache.org/repos/asf/aurora/blob/4a1fba3c/ui/package.json ---------------------------------------------------------------------- diff --git a/ui/package.json b/ui/package.json index 6e8ad7a..f4532df 100644 --- a/ui/package.json +++ b/ui/package.json @@ -4,6 +4,7 @@ "description": "UI project for Apache Aurora", "main": "index.js", "dependencies": { + "es6-shim": "^0.35.3", "moment": "^2.18.1", "react": "^16.0.0", "react-dom": "^16.0.0", @@ -40,7 +41,10 @@ "webpack": "^2.6.1" }, "jest": { - "moduleDirectories": ["./src/main/js", "node_modules"], + "moduleDirectories": [ + "./src/main/js", + "node_modules" + ], "setupFiles": [ "./test-setup.js" ] http://git-wip-us.apache.org/repos/asf/aurora/blob/4a1fba3c/ui/src/main/js/components/InstanceViz.js ---------------------------------------------------------------------- diff --git a/ui/src/main/js/components/InstanceViz.js b/ui/src/main/js/components/InstanceViz.js new file mode 100644 index 0000000..99efec4 --- /dev/null +++ b/ui/src/main/js/components/InstanceViz.js @@ -0,0 +1,17 @@ +import React from 'react'; +import { Link } from 'react-router-dom'; + +export default function InstanceViz({ instances, jobKey }) { + const {job: {role, environment, name}} = jobKey; + const className = (instances.length > 1000) + ? 'small' + : (instances.length > 100) ? 'medium' : 'big'; + + return (<ul className={`instance-grid ${className}`}> + {instances.map((i) => { + return (<Link key={i} to={`/beta/scheduler/${role}/${environment}/${name}/${i.instanceId}`}> + <li className={i.className} title={i.title} /> + </Link>); + })} + </ul>); +} http://git-wip-us.apache.org/repos/asf/aurora/blob/4a1fba3c/ui/src/main/js/components/Layout.js ---------------------------------------------------------------------- diff --git a/ui/src/main/js/components/Layout.js b/ui/src/main/js/components/Layout.js index 50d63e6..b63b0c7 100644 --- a/ui/src/main/js/components/Layout.js +++ b/ui/src/main/js/components/Layout.js @@ -1,8 +1,10 @@ import React from 'react'; +import Icon from 'components/Icon'; + import { addClass } from 'utils/Common'; -function ContentPanel({ children }) { +export function ContentPanel({ children }) { return <div className='content-panel'>{children}</div>; } @@ -10,6 +12,18 @@ export function StandardPanelTitle({ title }) { return <div className='content-panel-title'>{title}</div>; } +export function PanelSubtitle({ title }) { + return <div className='content-panel-subtitle'>{title}</div>; +} + +export function IconPanelTitle({ title, className, icon }) { + return (<div className={`content-icon-title ${className}`}> + <div className='content-icon-title-text'> + <Icon name={icon} /> {title} + </div> + </div>); +} + export default function PanelGroup({ children, title, noPadding }) { const extraClass = noPadding ? ' content-panel-fluid' : ''; return (<div className={addClass('content-panel-group', extraClass)}> @@ -25,7 +39,7 @@ export function PanelRow({ children }) { } export function Container({ children, className }) { - const width = 12 / children.length; + const width = 12 / (children.length || 1); return (<div className={addClass('container', className)}> <div className='row'> {React.Children.map(children, (c) => <div className={`col-md-${width}`}>{c}</div>)} http://git-wip-us.apache.org/repos/asf/aurora/blob/4a1fba3c/ui/src/main/js/components/Pagination.js ---------------------------------------------------------------------- diff --git a/ui/src/main/js/components/Pagination.js b/ui/src/main/js/components/Pagination.js index 7bf2c04..0aac09d 100644 --- a/ui/src/main/js/components/Pagination.js +++ b/ui/src/main/js/components/Pagination.js @@ -54,6 +54,10 @@ export default class Pagination extends React.Component { sort(data) { const { reverseSort, sortBy } = this.props; + if (!sortBy) { + return data; + } + const gte = reverseSort ? -1 : 1; const lte = reverseSort ? 1 : -1; if (typeof sortBy === 'function') { @@ -68,7 +72,7 @@ export default class Pagination extends React.Component { render() { const that = this; - const { data, isTable, maxPages, numberPerPage, renderer } = this.props; + const { data, isTable, maxPages, numberPerPage, renderer, hideIfSinglePage } = this.props; const { page } = this.state; // Apply the filter before we try to paginate. @@ -86,8 +90,10 @@ export default class Pagination extends React.Component { // but first attempts at this broke shallow rendering in enzyme. const elements = currentPageItems.map(renderer); + const numPages = Math.ceil(filtered.length / numberPerPage); + // The clickable page list. - const pagination = <PageNavigation + const pagination = (numPages === 1 && hideIfSinglePage) ? '' : <PageNavigation currentPage={page} maxPages={maxPages || 8} numPages={Math.ceil(filtered.length / numberPerPage)} http://git-wip-us.apache.org/repos/asf/aurora/blob/4a1fba3c/ui/src/main/js/components/TaskConfig.js ---------------------------------------------------------------------- diff --git a/ui/src/main/js/components/TaskConfig.js b/ui/src/main/js/components/TaskConfig.js new file mode 100644 index 0000000..b8531cb --- /dev/null +++ b/ui/src/main/js/components/TaskConfig.js @@ -0,0 +1,5 @@ +import React from 'react'; + +export default function TaskConfig({ config }) { + return <pre>{JSON.stringify(config, null, 2)}</pre>; +} http://git-wip-us.apache.org/repos/asf/aurora/blob/4a1fba3c/ui/src/main/js/components/Time.js ---------------------------------------------------------------------- diff --git a/ui/src/main/js/components/Time.js b/ui/src/main/js/components/Time.js new file mode 100644 index 0000000..0e8d984 --- /dev/null +++ b/ui/src/main/js/components/Time.js @@ -0,0 +1,6 @@ +import moment from 'moment'; +import React from 'react'; + +export function RelativeTime({ ts }) { + return <span>{moment(ts).fromNow()}</span>; +} http://git-wip-us.apache.org/repos/asf/aurora/blob/4a1fba3c/ui/src/main/js/components/UpdateConfig.js ---------------------------------------------------------------------- diff --git a/ui/src/main/js/components/UpdateConfig.js b/ui/src/main/js/components/UpdateConfig.js new file mode 100644 index 0000000..fac3c88 --- /dev/null +++ b/ui/src/main/js/components/UpdateConfig.js @@ -0,0 +1,12 @@ +import React from 'react'; + +import PanelGroup, { Container, StandardPanelTitle } from 'components/Layout'; +import TaskConfig from 'components/TaskConfig'; + +export default function UpdateConfig({ update }) { + return (<Container> + <PanelGroup noPadding title={<StandardPanelTitle title='Update Config' />}> + <TaskConfig config={update.update.instructions.desiredState.task} /> + </PanelGroup> + </Container>); +} http://git-wip-us.apache.org/repos/asf/aurora/blob/4a1fba3c/ui/src/main/js/components/UpdateDetails.js ---------------------------------------------------------------------- diff --git a/ui/src/main/js/components/UpdateDetails.js b/ui/src/main/js/components/UpdateDetails.js new file mode 100644 index 0000000..b9dd565 --- /dev/null +++ b/ui/src/main/js/components/UpdateDetails.js @@ -0,0 +1,28 @@ +import React from 'react'; + +import { Container, ContentPanel, StandardPanelTitle, PanelSubtitle } from 'components/Layout'; +import UpdateInstanceEvents from 'components/UpdateInstanceEvents'; +import UpdateInstanceSummary from 'components/UpdateInstanceSummary'; +import UpdateSettings from 'components/UpdateSettings'; +import UpdateStateMachine from 'components/UpdateStateMachine'; +import UpdateStatus from 'components/UpdateStatus'; +import UpdateTitle from 'components/UpdateTitle'; + +export default function UpdateDetails({ update }) { + return (<Container> + <div className='content-panel-group'> + <UpdateTitle update={update} /> + <ContentPanel><UpdateStatus update={update} /></ContentPanel> + <PanelSubtitle title='Update History' /> + <ContentPanel><UpdateStateMachine update={update} /></ContentPanel> + <PanelSubtitle title='Update Settings' /> + <ContentPanel><UpdateSettings update={update} /></ContentPanel> + </div> + <div className='content-panel-group'> + <StandardPanelTitle title='Instance Overview' /> + <ContentPanel><UpdateInstanceSummary update={update} /></ContentPanel> + <PanelSubtitle title='Instance Events' /> + <div className='content-panel fluid'><UpdateInstanceEvents update={update} /></div> + </div> + </Container>); +} http://git-wip-us.apache.org/repos/asf/aurora/blob/4a1fba3c/ui/src/main/js/components/UpdateInstanceEvents.js ---------------------------------------------------------------------- diff --git a/ui/src/main/js/components/UpdateInstanceEvents.js b/ui/src/main/js/components/UpdateInstanceEvents.js new file mode 100644 index 0000000..f0dfae2 --- /dev/null +++ b/ui/src/main/js/components/UpdateInstanceEvents.js @@ -0,0 +1,101 @@ +import moment from 'moment'; +import React from 'react'; +import { Link } from 'react-router-dom'; + +import Icon from 'components/Icon'; +import Pagination from 'components/Pagination'; +import StateMachine from 'components/StateMachine'; + +import { addClass, sort } from 'utils/Common'; +import { UPDATE_ACTION } from 'utils/Thrift'; +import { actionDispatcher, getClassForUpdateAction } from 'utils/Update'; + +const instanceEventIcon = actionDispatcher({ + success: (e) => <Icon name='ok' />, + warning: (e) => <Icon name='warning-sign' />, + error: (e) => <Icon name='remove' />, + inProgress: (e) => <Icon name='play-circle' /> +}); + +export class InstanceEvent extends React.Component { + constructor(props) { + super(props); + this.state = { expanded: props.expanded || false }; + } + + _stateMachine(events) { + const states = events.map((e, i) => { + return { + className: addClass( + getClassForUpdateAction(e.action), + (i === events.length - 1) ? ' active' : ''), + state: UPDATE_ACTION[e.action], + timestamp: e.timestampMs + }; + }); + + return (<div className='update-instance-history'> + <StateMachine + className={getClassForUpdateAction(events[events.length - 1].action)} + states={states} /> + </div>); + } + + expand() { + this.setState({ expanded: !this.state.expanded }); + } + + render() { + const {events, instanceId, jobKey: {job: {role, environment, name}}} = this.props; + const sorted = sort(events, (e) => e.timestampMs); + const stateMachine = this.state.expanded ? this._stateMachine(sorted) : ''; + const icon = this.state.expanded ? <Icon name='chevron-down' /> : <Icon name='chevron-right' />; + const latestEvent = sorted[sorted.length - 1]; + return (<div className='update-instance-event-container'> + <div className='update-instance-event' onClick={(e) => this.expand()}> + {icon} + <span className='update-instance-event-id'> + <Link to={`/beta/scheduler/${role}/${environment}/${name}/${instanceId}`}> + #{instanceId} + </Link> + </span> + <span className='update-instance-event-status'> + {UPDATE_ACTION[latestEvent.action]} + <span className={getClassForUpdateAction(latestEvent.action)}> + {instanceEventIcon(latestEvent)} + </span> + </span> + <span className='update-instance-event-time'> + {moment(latestEvent.timestampMs).utc().format('HH:mm:ss') + ' UTC'} + </span> + </div> + {stateMachine} + </div>); + } +}; + +export default function UpdateInstanceEvents({ update }) { + const sortedEvents = sort(update.instanceEvents, (e) => e.timestampMs, true); + const instanceMap = {}; + const eventOrder = []; + sortedEvents.forEach((e) => { + const existing = instanceMap[e.instanceId]; + if (existing) { + instanceMap[e.instanceId].push(e); + } else { + eventOrder.push(e.instanceId); + instanceMap[e.instanceId] = [e]; + } + }); + + return (<div className='instance-events'> + <Pagination + data={eventOrder} + hideIfSinglePage + numberPerPage={10} + renderer={(instanceId) => <InstanceEvent + events={instanceMap[instanceId]} + instanceId={instanceId} + jobKey={update.update.summary.key} />} /> + </div>); +} http://git-wip-us.apache.org/repos/asf/aurora/blob/4a1fba3c/ui/src/main/js/components/UpdateInstanceSummary.js ---------------------------------------------------------------------- diff --git a/ui/src/main/js/components/UpdateInstanceSummary.js b/ui/src/main/js/components/UpdateInstanceSummary.js new file mode 100644 index 0000000..3cbc28e --- /dev/null +++ b/ui/src/main/js/components/UpdateInstanceSummary.js @@ -0,0 +1,22 @@ +import React from 'react'; + +import InstanceViz from 'components/InstanceViz'; + +import { instanceSummary, updateStats } from 'utils/Update'; + +function UpdateStats({ update }) { + const stats = updateStats(update); + return (<div className='update-summary-stats'> + <h5>Instance Summary</h5> + <span className='stats'> + {stats.instancesUpdated} / {stats.totalInstancesToBeUpdated} ({stats.progress}%) + </span> + </div>); +}; + +export default function UpdateInstanceSummary({ update }) { + return (<div> + <UpdateStats update={update} /> + <InstanceViz instances={instanceSummary(update)} jobKey={update.update.summary.key} /> + </div>); +} http://git-wip-us.apache.org/repos/asf/aurora/blob/4a1fba3c/ui/src/main/js/components/UpdateList.js ---------------------------------------------------------------------- diff --git a/ui/src/main/js/components/UpdateList.js b/ui/src/main/js/components/UpdateList.js new file mode 100644 index 0000000..3f57669 --- /dev/null +++ b/ui/src/main/js/components/UpdateList.js @@ -0,0 +1,47 @@ +import React from 'react'; +import { Link } from 'react-router-dom'; + +import Loading from 'components/Loading'; +import Pagination from 'components/Pagination'; +import { RelativeTime } from 'components/Time'; + +import { isNully } from 'utils/Common'; +import { UPDATE_STATUS } from 'utils/Thrift'; +import { getClassForUpdateStatus } from 'utils/Update'; + +function UpdateListItem({ summary }) { + const {job: {role, environment, name}, id} = summary.key; + return (<div className='update-list-item'> + <span className={`img-circle ${getClassForUpdateStatus(summary.state.status)}`} /> + <div className='update-list-item-details'> + <span className='update-list-item-status'> + <Link + className='update-list-job' + to={`/beta/scheduler/${role}/${environment}/${name}/update/${id}`}> + {role}/{environment}/{name} + </Link> • <span className='update-list-status'> + {UPDATE_STATUS[summary.state.status]} + </span> + </span> + started by <span className='update-list-user'> + {summary.user} </span> <RelativeTime ts={summary.state.createdTimestampMs} /> + </div> + <span className='update-list-last-updated'> + updated <RelativeTime ts={summary.state.lastModifiedTimestampMs} /> + </span> + </div>); +} + +export default function UpdateList({ updates }) { + if (isNully(updates)) { + return <Loading />; + } + + return (<div className='update-list'> + <Pagination + data={updates} + hideIfSinglePage + numberPerPage={25} + renderer={(u) => <UpdateListItem key={u.key.id} summary={u} />} /> + </div>); +} http://git-wip-us.apache.org/repos/asf/aurora/blob/4a1fba3c/ui/src/main/js/components/UpdateSettings.js ---------------------------------------------------------------------- diff --git a/ui/src/main/js/components/UpdateSettings.js b/ui/src/main/js/components/UpdateSettings.js new file mode 100644 index 0000000..d756f59 --- /dev/null +++ b/ui/src/main/js/components/UpdateSettings.js @@ -0,0 +1,30 @@ +import moment from 'moment'; +import React from 'react'; + +export default function UpdateSettings({ update }) { + const settings = update.update.instructions.settings; + return (<div> + <table className='update-settings'> + <tr> + <td>Batch Size</td> + <td>{settings.updateGroupSize}</td> + </tr> + <tr> + <td>Max Failures Per Instance</td> + <td>{settings.maxPerInstanceFailures}</td> + </tr> + <tr> + <td>Max Failed Instances</td> + <td>{settings.maxFailedInstances}</td> + </tr> + <tr> + <td>Minimum Waiting Time in Running</td> + <td>{moment.duration(settings.minWaitInInstanceRunningMs).humanize()}</td> + </tr> + <tr> + <td>Rollback On Failure?</td> + <td>{settings.rollbackOnFailure ? 'yes' : 'no'}</td> + </tr> + </table> + </div>); +} http://git-wip-us.apache.org/repos/asf/aurora/blob/4a1fba3c/ui/src/main/js/components/UpdateStateMachine.js ---------------------------------------------------------------------- diff --git a/ui/src/main/js/components/UpdateStateMachine.js b/ui/src/main/js/components/UpdateStateMachine.js new file mode 100644 index 0000000..ab1e85a --- /dev/null +++ b/ui/src/main/js/components/UpdateStateMachine.js @@ -0,0 +1,21 @@ +import React from 'react'; + +import StateMachine from 'components/StateMachine'; + +import { addClass } from 'utils/Common'; +import { UPDATE_STATUS } from 'utils/Thrift'; +import { getClassForUpdateStatus } from 'utils/Update'; + +export default function UpdateStateMachine({ update }) { + const events = update.updateEvents; + const states = events.map((e, i) => ({ + className: addClass( + getClassForUpdateStatus(e.status), + (i === events.length - 1) ? ' active' : ''), + state: UPDATE_STATUS[e.status], + message: e.message, + timestamp: e.timestampMs + })); + const className = getClassForUpdateStatus(events[events.length - 1].status); + return <StateMachine className={addClass('update-state-machine', className)} states={states} />; +} http://git-wip-us.apache.org/repos/asf/aurora/blob/4a1fba3c/ui/src/main/js/components/UpdateStatus.js ---------------------------------------------------------------------- diff --git a/ui/src/main/js/components/UpdateStatus.js b/ui/src/main/js/components/UpdateStatus.js new file mode 100644 index 0000000..7d37430 --- /dev/null +++ b/ui/src/main/js/components/UpdateStatus.js @@ -0,0 +1,22 @@ +import React from 'react'; + +import UpdateTime from 'components/UpdateTime'; + +import { UPDATE_STATUS } from 'utils/Thrift'; +import { isInProgressUpdate } from 'utils/Update'; + +export default function UpdateStatus({ update }) { + const time = isInProgressUpdate(update) ? '' : <UpdateTime update={update} />; + return (<div> + <div className='update-byline'> + <span> + Update started by <strong>{update.update.summary.user}</strong> + </span> + <span>•</span> + <span> + Status: <strong>{UPDATE_STATUS[update.update.summary.state.status]}</strong> + </span> + </div> + {time} + </div>); +} http://git-wip-us.apache.org/repos/asf/aurora/blob/4a1fba3c/ui/src/main/js/components/UpdateTime.js ---------------------------------------------------------------------- diff --git a/ui/src/main/js/components/UpdateTime.js b/ui/src/main/js/components/UpdateTime.js new file mode 100644 index 0000000..e55d376 --- /dev/null +++ b/ui/src/main/js/components/UpdateTime.js @@ -0,0 +1,33 @@ +import moment from 'moment'; +import React from 'react'; + +import { RelativeTime } from 'components/Time'; + +function UpdateTimeDisplay({ timestamp }) { + return (<div className='update-time'> + <span>{moment(timestamp).utc().format('ddd, MMM Do')}</span> + <h4>{moment(timestamp).utc().format('HH:mm')}</h4> + <span className='time-ago'><RelativeTime ts={timestamp} /></span> + </div>); +}; + +function UpdateDuration({ update }) { + const duration = (update.update.summary.state.lastModifiedTimestampMs - + update.update.summary.state.createdTimestampMs); + return <div className='update-duration'>Duration: {moment.duration(duration).humanize()}</div>; +}; + +function UpdateTimeRange({ update }) { + return (<div className='update-time-range'> + <UpdateTimeDisplay timestamp={update.update.summary.state.createdTimestampMs} /> + <h5>~</h5> + <UpdateTimeDisplay timestamp={update.update.summary.state.lastModifiedTimestampMs} /> + </div>); +}; + +export default function UpdateTime({ update }) { + return (<div> + <UpdateTimeRange update={update} /> + <UpdateDuration update={update} /> + </div>); +} http://git-wip-us.apache.org/repos/asf/aurora/blob/4a1fba3c/ui/src/main/js/components/UpdateTitle.js ---------------------------------------------------------------------- diff --git a/ui/src/main/js/components/UpdateTitle.js b/ui/src/main/js/components/UpdateTitle.js new file mode 100644 index 0000000..cdeac6a --- /dev/null +++ b/ui/src/main/js/components/UpdateTitle.js @@ -0,0 +1,26 @@ +import React from 'react'; + +import { IconPanelTitle } from 'components/Layout'; + +import { statusDispatcher } from 'utils/Update'; + +const titleDispatch = { + success: (update) => { + return <IconPanelTitle className='success' icon='ok-sign' title='Update Successful' />; + }, + warning: (update) => { + return <IconPanelTitle className='attention' icon='warning-sign' title='Update Paused' />; + }, + error: (update) => { + return <IconPanelTitle className='error' icon='remove-sign' title='Update Failed' />; + }, + inProgress: (update) => { + return <IconPanelTitle className='highlight' icon='play-circle' title='Update In Progress' />; + } +}; + +const titleDispatcher = statusDispatcher(titleDispatch); + +export default function UpdateTitle({ update }) { + return titleDispatcher(update); +} http://git-wip-us.apache.org/repos/asf/aurora/blob/4a1fba3c/ui/src/main/js/components/__tests__/InstanceViz-test.js ---------------------------------------------------------------------- diff --git a/ui/src/main/js/components/__tests__/InstanceViz-test.js b/ui/src/main/js/components/__tests__/InstanceViz-test.js new file mode 100644 index 0000000..25efbf5 --- /dev/null +++ b/ui/src/main/js/components/__tests__/InstanceViz-test.js @@ -0,0 +1,41 @@ +import React from 'react'; +import { shallow } from 'enzyme'; + +import InstanceViz from '../InstanceViz'; + +import { range } from 'utils/Common'; + +function generateInstances(n) { + return range(0, n - 1).map((i) => { + return { + className: 'okay', + instanceId: i, + title: `test-${i}` + }; + }); +} + +const jobKey = {job: {role: 'test', environment: 'test', name: 'test'}}; + +describe('InstanceViz', () => { + it('Should apply the small class to large numbers of instances', () => { + const el = shallow(<InstanceViz instances={generateInstances(1001)} jobKey={jobKey} />); + expect(el.find('ul.small').length).toBe(1); + expect(el.find('ul.medium').length).toBe(0); + expect(el.find('ul.big').length).toBe(0); + }); + + it('Should apply the medium class to medium numbers of instances', () => { + const el = shallow(<InstanceViz instances={generateInstances(101)} jobKey={jobKey} />); + expect(el.find('ul.small').length).toBe(0); + expect(el.find('ul.medium').length).toBe(1); + expect(el.find('ul.big').length).toBe(0); + }); + + it('Should apply the big class to small numbers of instances', () => { + const el = shallow(<InstanceViz instances={generateInstances(100)} jobKey={jobKey} />); + expect(el.find('ul.small').length).toBe(0); + expect(el.find('ul.medium').length).toBe(0); + expect(el.find('ul.big').length).toBe(1); + }); +}); http://git-wip-us.apache.org/repos/asf/aurora/blob/4a1fba3c/ui/src/main/js/components/__tests__/Pagination-test.js ---------------------------------------------------------------------- diff --git a/ui/src/main/js/components/__tests__/Pagination-test.js b/ui/src/main/js/components/__tests__/Pagination-test.js index f2b72e9..8426527 100644 --- a/ui/src/main/js/components/__tests__/Pagination-test.js +++ b/ui/src/main/js/components/__tests__/Pagination-test.js @@ -53,6 +53,20 @@ describe('Pagination', () => { el.containsAllMatchingElements([<PageNavigation currentPage={1} numPages={1} />])).toBe(true); }); + it('Should not show PageNavigation when hide single page is set', () => { + const el = shallow( + <Pagination data={data} hideIfSinglePage numberPerPage={25} renderer={render} />); + expect(el.find(Row).length).toBe(10); + expect(el.find(PageNavigation).length).toBe(0); + }); + + it('Should show PageNavigation when hide single page is set, but theres multiple pages', () => { + const el = shallow( + <Pagination data={data} hideIfSinglePage numberPerPage={2} renderer={render} />); + expect(el.find(Row).length).toBe(2); + expect(el.find(PageNavigation).length).toBe(1); + }); + it('Should sort correctly', () => { const el = shallow( <Pagination data={data} numberPerPage={3} renderer={render} sortBy='name' />); @@ -75,6 +89,17 @@ describe('Pagination', () => { <PageNavigation currentPage={1} numPages={4} />])).toBe(true); }); + it('Should respect natural order when sortBy is omitted', () => { + const el = shallow( + <Pagination data={data} numberPerPage={3} renderer={render} />); + expect(el.find(Row).length).toBe(3); + expect(el.containsAllMatchingElements([ + <Row key={1} />, + <Row key={2} />, + <Row key={3} />, + <PageNavigation currentPage={1} numPages={4} />])).toBe(true); + }); + it('Should filter correctly', () => { const el = shallow(<Pagination data={data} http://git-wip-us.apache.org/repos/asf/aurora/blob/4a1fba3c/ui/src/main/js/components/__tests__/UpdateInstanceEvents-test.js ---------------------------------------------------------------------- diff --git a/ui/src/main/js/components/__tests__/UpdateInstanceEvents-test.js b/ui/src/main/js/components/__tests__/UpdateInstanceEvents-test.js new file mode 100644 index 0000000..4cbad09 --- /dev/null +++ b/ui/src/main/js/components/__tests__/UpdateInstanceEvents-test.js @@ -0,0 +1,40 @@ +import React from 'react'; +import { shallow } from 'enzyme'; + +import Pagination from '../Pagination'; +import StateMachine from '../StateMachine'; +import UpdateInstanceEvents, { InstanceEvent } from '../UpdateInstanceEvents'; + +import { InstanceUpdateEventBuilder, UpdateDetailsBuilder } from 'test-utils/UpdateBuilders'; + +describe('UpdateInstanceEvents', () => { + it('Should reverse sort each instance by the latest instance event', () => { + const update = UpdateDetailsBuilder.instanceEvents([ + InstanceUpdateEventBuilder.instanceId(0).timestampMs(1).build(), + InstanceUpdateEventBuilder.instanceId(1).timestampMs(0).build(), + InstanceUpdateEventBuilder.instanceId(2).timestampMs(0).build(), + InstanceUpdateEventBuilder.instanceId(2).timestampMs(2).build(), + InstanceUpdateEventBuilder.instanceId(0).timestampMs(5).build(), + InstanceUpdateEventBuilder.instanceId(3).timestampMs(0).build(), + InstanceUpdateEventBuilder.instanceId(3).timestampMs(20).build(), + InstanceUpdateEventBuilder.instanceId(4).timestampMs(3).build()]).build(); + + const el = shallow(<UpdateInstanceEvents update={update} />); + expect(el.find(Pagination).first().props().data).toEqual([3, 0, 4, 2, 1]); + }); +}); + +describe('InstanceEvent', () => { + const jobKey = {job: {role: 'role', environment: 'env', name: 'name'}}; + + it('Should support expand toggle', () => { + const events = [ + InstanceUpdateEventBuilder.instanceId(0).timestampMs(1).build(), + InstanceUpdateEventBuilder.instanceId(0).timestampMs(5).build()]; + + const el = shallow(<InstanceEvent events={events} instanceId={0} jobKey={jobKey} />); + expect(el.find(StateMachine).length).toBe(0); + el.find('.update-instance-event').simulate('click'); + expect(el.find(StateMachine).length).toBe(1); + }); +}); http://git-wip-us.apache.org/repos/asf/aurora/blob/4a1fba3c/ui/src/main/js/components/__tests__/UpdateList-test.js ---------------------------------------------------------------------- diff --git a/ui/src/main/js/components/__tests__/UpdateList-test.js b/ui/src/main/js/components/__tests__/UpdateList-test.js new file mode 100644 index 0000000..584df9d --- /dev/null +++ b/ui/src/main/js/components/__tests__/UpdateList-test.js @@ -0,0 +1,17 @@ +import React from 'react'; +import { shallow } from 'enzyme'; + +import Loading from '../Loading'; +import UpdateList from '../UpdateList'; + +describe('UpdateList', () => { + it('Handles null by showing Loading', () => { + const el = shallow(<UpdateList />); + expect(el.contains(<Loading />)).toBe(true); + }); + + it('Does not show loading when data is passed, even an empty list', () => { + const el = shallow(<UpdateList updates={[]} />); + expect(el.contains(<Loading />)).toBe(false); + }); +}); http://git-wip-us.apache.org/repos/asf/aurora/blob/4a1fba3c/ui/src/main/js/components/__tests__/UpdateStatus-test.js ---------------------------------------------------------------------- diff --git a/ui/src/main/js/components/__tests__/UpdateStatus-test.js b/ui/src/main/js/components/__tests__/UpdateStatus-test.js new file mode 100644 index 0000000..0bd14b2 --- /dev/null +++ b/ui/src/main/js/components/__tests__/UpdateStatus-test.js @@ -0,0 +1,23 @@ +import React from 'react'; +import { shallow } from 'enzyme'; + +import UpdateStatus from '../UpdateStatus'; +import UpdateTime from '../UpdateTime'; + +import { builderWithStatus } from 'test-utils/UpdateBuilders'; + +describe('UpdateStatus', () => { + it('Should show UpdateTime when update terminal', () => { + const update = builderWithStatus(JobUpdateStatus.ROLLED_FORWARD).build(); + + const el = shallow(<UpdateStatus update={update} />); + expect(el.contains(<UpdateTime update={update} />)).toBe(true); + }); + + it('Should NOT show UpdateTime when update in-progress', () => { + const update = builderWithStatus(JobUpdateStatus.ROLLING_FORWARD).build(); + + const el = shallow(<UpdateStatus update={update} />); + expect(el.find(UpdateTime).length).toBe(0); + }); +}); http://git-wip-us.apache.org/repos/asf/aurora/blob/4a1fba3c/ui/src/main/js/index.js ---------------------------------------------------------------------- diff --git a/ui/src/main/js/index.js b/ui/src/main/js/index.js index 8f07734..30646f7 100644 --- a/ui/src/main/js/index.js +++ b/ui/src/main/js/index.js @@ -7,6 +7,8 @@ import Navigation from 'components/Navigation'; import Home from 'pages/Home'; import Instance from 'pages/Instance'; import Jobs from 'pages/Jobs'; +import Update from 'pages/Update'; +import Updates from 'pages/Updates'; import styles from '../sass/app.scss'; // eslint-disable-line no-unused-vars @@ -24,8 +26,11 @@ const SchedulerUI = () => ( component={injectApi(Instance)} exact path='/beta/scheduler/:role/:environment/:name/:instance' /> - <Route component={Home} exact path='/beta/scheduler/:role/:environment/:name/update/:uid' /> - <Route component={Home} exact path='/beta/updates' /> + <Route + component={injectApi(Update)} + exact + path='/beta/scheduler/:role/:environment/:name/update/:uid' /> + <Route component={injectApi(Updates)} exact path='/beta/updates' /> </div> </Router> ); http://git-wip-us.apache.org/repos/asf/aurora/blob/4a1fba3c/ui/src/main/js/pages/Update.js ---------------------------------------------------------------------- diff --git a/ui/src/main/js/pages/Update.js b/ui/src/main/js/pages/Update.js new file mode 100644 index 0000000..c900269 --- /dev/null +++ b/ui/src/main/js/pages/Update.js @@ -0,0 +1,54 @@ +import React from 'react'; + +import Breadcrumb from 'components/Breadcrumb'; +import Loading from 'components/Loading'; +import UpdateConfig from 'components/UpdateConfig'; +import UpdateDetails from 'components/UpdateDetails'; + +export default class Update extends React.Component { + constructor(props) { + super(props); + this.state = { loading: true }; + } + + componentWillMount() { + const { role, environment, name, uid } = this.props.match.params; + + const job = new JobKey(); + job.role = role; + job.environment = environment; + job.name = name; + + const key = new JobUpdateKey(); + key.job = job; + key.id = uid; + + const query = new JobUpdateQuery(); + query.key = key; + + const that = this; + this.props.api.getJobUpdateDetails(null, query, (response) => { + const update = response.result.getJobUpdateDetailsResult.detailsList[0]; + that.setState({ cluster: response.serverInfo.clusterName, loading: false, update }); + }); + } + + render() { + const { role, environment, name, uid } = this.props.match.params; + + if (this.state.loading) { + return <Loading />; + } + + return (<div className='update-page'> + <Breadcrumb + cluster={this.state.cluster} + env={environment} + name={name} + role={role} + update={uid} /> + <UpdateDetails update={this.state.update} /> + <UpdateConfig update={this.state.update} /> + </div>); + } +} http://git-wip-us.apache.org/repos/asf/aurora/blob/4a1fba3c/ui/src/main/js/pages/Updates.js ---------------------------------------------------------------------- diff --git a/ui/src/main/js/pages/Updates.js b/ui/src/main/js/pages/Updates.js new file mode 100644 index 0000000..44b9a11 --- /dev/null +++ b/ui/src/main/js/pages/Updates.js @@ -0,0 +1,66 @@ +import React from 'react'; + +import Breadcrumb from 'components/Breadcrumb'; +import PanelGroup, { Container, StandardPanelTitle } from 'components/Layout'; +import UpdateList from 'components/UpdateList'; + +import { getInProgressStates, getTerminalStates } from 'utils/Update'; + +export const MAX_QUERY_SIZE = 100; + +export class UpdatesFetcher extends React.Component { + constructor(props) { + super(props); + this.state = { updates: null }; + } + + componentWillMount() { + const that = this; + const query = new JobUpdateQuery(); + query.updateStatuses = this.props.states; + query.limit = MAX_QUERY_SIZE; + this.props.api.getJobUpdateSummaries(query, (response) => { + const updates = response.result.getJobUpdateSummariesResult.updateSummaries; + that.setState({updates}); + that.props.clusterFn(response.serverInfo.clusterName); + }); + } + + render() { + return (<Container> + <PanelGroup noPadding title={<StandardPanelTitle title={this.props.title} />}> + <UpdateList updates={this.state.updates} /> + </PanelGroup> + </Container>); + } +} + +export default class Updates extends React.Component { + constructor(props) { + super(props); + this.state = { cluster: null }; + this.clusterFn = this.setCluster.bind(this); + } + + setCluster(cluster) { + // TODO(dmcg): We should just have the Scheduler return the cluster as a global. + this.setState({cluster}); + } + + render() { + const api = this.props.api; + return (<div className='update-page'> + <Breadcrumb cluster={this.state.cluster} /> + <UpdatesFetcher + api={api} + clusterFn={this.clusterFn} + states={getInProgressStates()} + title='Updates In Progress' /> + <UpdatesFetcher + api={api} + clusterFn={this.clusterFn} + states={getTerminalStates()} + title='Recently Completed Updates' /> + </div>); + } +} http://git-wip-us.apache.org/repos/asf/aurora/blob/4a1fba3c/ui/src/main/js/pages/__tests__/Update-test.js ---------------------------------------------------------------------- diff --git a/ui/src/main/js/pages/__tests__/Update-test.js b/ui/src/main/js/pages/__tests__/Update-test.js new file mode 100644 index 0000000..570a999 --- /dev/null +++ b/ui/src/main/js/pages/__tests__/Update-test.js @@ -0,0 +1,56 @@ +import React from 'react'; +import { shallow } from 'enzyme'; + +import Update from '../Update'; + +import Breadcrumb from 'components/Breadcrumb'; +import Loading from 'components/Loading'; +import UpdateConfig from 'components/UpdateConfig'; +import UpdateDetails from 'components/UpdateDetails'; + +const TEST_CLUSTER = 'test-cluster'; + +const params = { + role: 'test-role', + environment: 'test-env', + name: 'test-job', + instance: '1', + uid: 'update-id' +}; + +function createMockApi(update) { + const api = {}; + api.getJobUpdateDetails = (id, query, handler) => handler({ + result: { + getJobUpdateDetailsResult: { + detailsList: [update] + } + }, + serverInfo: { + clusterName: TEST_CLUSTER + } + }); + return api; +} + +const update = {}; // only testing pass-through here... + +describe('Update', () => { + it('Should render Loading before data is fetched', () => { + expect(shallow(<Update + api={{getJobUpdateDetails: () => {}}} + match={{params: params}} />).contains(<Loading />)).toBe(true); + }); + + it('Should render page elements when update is fetched', () => { + const el = shallow(<Update api={createMockApi(update)} match={{params: params}} />); + expect(el.contains(<Breadcrumb + cluster={TEST_CLUSTER} + env={params.environment} + name={params.name} + role={params.role} + update={params.uid} />)).toBe(true); + expect(el.contains(<UpdateConfig update={update} />)).toBe(true); + expect(el.contains(<UpdateDetails update={update} />)).toBe(true); + }); +}); http://git-wip-us.apache.org/repos/asf/aurora/blob/4a1fba3c/ui/src/main/js/pages/__tests__/Updates-test.js ---------------------------------------------------------------------- diff --git a/ui/src/main/js/pages/__tests__/Updates-test.js b/ui/src/main/js/pages/__tests__/Updates-test.js new file mode 100644 index 0000000..8cc3315 --- /dev/null +++ b/ui/src/main/js/pages/__tests__/Updates-test.js @@ -0,0 +1,33 @@ +import React from 'react'; +import { shallow } from 'enzyme'; + +import { UpdatesFetcher } from '../Updates'; +import UpdateList from 'components/UpdateList'; + +const TEST_CLUSTER = 'test-cluster'; + +function createMockApi(updates) { + const api = {}; + api.getJobUpdateSummaries = (query, handler) => handler({ + result: { + getJobUpdateSummariesResult: { + updateSummaries: updates + } + }, + serverInfo: { + clusterName: TEST_CLUSTER + } + }); + return api; +} + +const updates = [{}]; // only testing pass-through here... +const states = []; + +describe('UpdatesFetcher', () => { + it('Should render update list with updates when mounted', () => { + const el = shallow( + <UpdatesFetcher api={createMockApi(updates)} clusterFn={() => {}} states={states} />); + expect(el.contains(<UpdateList updates={updates} />)); + }); +}); http://git-wip-us.apache.org/repos/asf/aurora/blob/4a1fba3c/ui/src/main/js/test-utils/UpdateBuilders.js ---------------------------------------------------------------------- diff --git a/ui/src/main/js/test-utils/UpdateBuilders.js b/ui/src/main/js/test-utils/UpdateBuilders.js new file mode 100644 index 0000000..2764f0e --- /dev/null +++ b/ui/src/main/js/test-utils/UpdateBuilders.js @@ -0,0 +1,86 @@ +import createBuilder from 'test-utils/Builder'; + +import { TaskConfigBuilder } from './TaskBuilders'; + +const USER = 'update-user'; +const UPDATE_ID = 'update-id'; +const JOB_KEY = { + role: 'test-role', + environment: 'test-env', + name: 'test-name' +}; +const UPDATE_KEY = { + job: JOB_KEY, + id: UPDATE_ID +}; + +export default { + USER +}; + +export const UpdateSettingsBuilder = createBuilder({ + updateGroupSize: 1, + maxPerInstanceFailures: 0, + maxFailedInstances: 0, + minWaitInInstanceRunningMs: 1, + rollbackOnFailure: true, + updateOnlyTheseInstances: [], + waitForBatchCompletion: false +}); + +export const UpdateEventBuilder = createBuilder({ + status: JobUpdateStatus.ROLLING_FORWARD, + timestampMs: 0, + user: USER, + message: '' +}); + +export const InstanceUpdateEventBuilder = createBuilder({ + instanceId: 0, + timestampMs: 0, + action: JobUpdateAction.INSTANCE_UPDATING +}); + +export const InstanceTaskConfigBuilder = createBuilder({ + task: TaskConfigBuilder.build(), + instances: [{first: 0, last: 0}] +}); + +export const UpdateInstructionsBuilder = createBuilder({ + initialState: [InstanceTaskConfigBuilder.build()], + desiredState: InstanceTaskConfigBuilder.task( + TaskConfigBuilder.resources([{numCpus: 2, ramMb: 2048, diskMb: 2048}])).build(), + settings: UpdateSettingsBuilder.build() +}); + +export const UpdateStateBuilder = createBuilder({ + status: JobUpdateStatus.ROLLING_FORWARD, + createdTimestampMs: 0, + lastModifiedTimestampMs: 60000 +}); + +export const UpdateSummaryBuilder = createBuilder({ + key: UPDATE_KEY, + user: USER, + state: UpdateStateBuilder.build(), + metadata: [] +}); + +export const UpdateBuilder = createBuilder({ + summary: UpdateSummaryBuilder.build(), + instructions: UpdateInstructionsBuilder.build() +}); + +export const UpdateDetailsBuilder = createBuilder({ + update: UpdateBuilder.build(), + updateEvents: [UpdateEventBuilder.build()], + instanceEvents: [InstanceUpdateEventBuilder.build()] +}); + +export function builderWithStatus(updateStatus) { + return UpdateDetailsBuilder.update( + UpdateBuilder.summary( + UpdateSummaryBuilder.state(UpdateStateBuilder.status(updateStatus).build()).build() + ).build() + ).updateEvents([UpdateEventBuilder.status(updateStatus).build()]); +} http://git-wip-us.apache.org/repos/asf/aurora/blob/4a1fba3c/ui/src/main/js/utils/Common.js ---------------------------------------------------------------------- diff --git a/ui/src/main/js/utils/Common.js b/ui/src/main/js/utils/Common.js index be8766c..8f2da7c 100644 --- a/ui/src/main/js/utils/Common.js +++ b/ui/src/main/js/utils/Common.js @@ -20,3 +20,19 @@ export function addClass(original, maybeClass) { export function clone(obj) { return JSON.parse(JSON.stringify(obj)); } + +export function sort(arr, prop, reverse = false) { + return arr.sort((a, b) => { + if (prop(a) === prop(b)) { + return 0; + } + if (prop(a) < prop(b)) { + return reverse ? 1 : -1; + } + return reverse ? -1 : 1; + }); +} + +export function range(start, end) { + return [...Array(1 + end - start).keys()].map((i) => start + i); +} http://git-wip-us.apache.org/repos/asf/aurora/blob/4a1fba3c/ui/src/main/js/utils/Thrift.js ---------------------------------------------------------------------- diff --git a/ui/src/main/js/utils/Thrift.js b/ui/src/main/js/utils/Thrift.js index b247e36..4336bd1 100644 --- a/ui/src/main/js/utils/Thrift.js +++ b/ui/src/main/js/utils/Thrift.js @@ -1,6 +1,8 @@ import { invert } from 'utils/Common'; export const SCHEDULE_STATUS = invert(ScheduleStatus); +export const UPDATE_STATUS = invert(JobUpdateStatus); +export const UPDATE_ACTION = invert(JobUpdateAction); export const OKAY_SCHEDULE_STATUS = [ ScheduleStatus.RUNNING, @@ -25,9 +27,48 @@ export const ERROR_SCHEDULE_STATUS = [ ScheduleStatus.FAILED ]; +export const OKAY_UPDATE_STATUS = [ + JobUpdateStatus.ROLLED_FORWARD +]; + +export const WARNING_UPDATE_STATUS = [ + JobUpdateStatus.ROLL_FORWARD_AWAITING_PULSE, + JobUpdateStatus.ROLL_FORWARD_PAUSED +]; + +export const ERROR_UPDATE_STATUS = [ + JobUpdateStatus.ROLLING_BACK, + JobUpdateStatus.ROLLED_BACK, + JobUpdateStatus.ROLL_BACK_PAUSED, + JobUpdateStatus.ABORTED, + JobUpdateStatus.ERROR, + JobUpdateStatus.FAILED, + JobUpdateStatus.ROLL_BACK_AWAITING_PULSE +]; + +export const OKAY_UPDATE_ACTION = [ + JobUpdateAction.INSTANCE_UPDATED +]; + +export const WARNING_UPDATE_ACTION = [ + JobUpdateAction.INSTANCE_ROLLING_BACK, + JobUpdateAction.INSTANCE_ROLLED_BACK +]; + +export const ERROR_UPDATE_ACTION = [ + JobUpdateAction.INSTANCE_UPDATE_FAILED, + JobUpdateAction.INSTANCE_ROLLBACK_FAILED +]; + export default { OKAY_SCHEDULE_STATUS, WARNING_SCHEDULE_STATUS, ERROR_SCHEDULE_STATUS, - USER_WAIT_SCHEDULE_STATUS + USER_WAIT_SCHEDULE_STATUS, + OKAY_UPDATE_STATUS, + WARNING_UPDATE_STATUS, + ERROR_UPDATE_STATUS, + OKAY_UPDATE_ACTION, + WARNING_UPDATE_ACTION, + ERROR_UPDATE_ACTION }; http://git-wip-us.apache.org/repos/asf/aurora/blob/4a1fba3c/ui/src/main/js/utils/Update.js ---------------------------------------------------------------------- diff --git a/ui/src/main/js/utils/Update.js b/ui/src/main/js/utils/Update.js new file mode 100644 index 0000000..dee2393 --- /dev/null +++ b/ui/src/main/js/utils/Update.js @@ -0,0 +1,163 @@ +import { sort } from 'utils/Common'; +import Thrift, { UPDATE_ACTION } from 'utils/Thrift'; + +export function isSuccessfulUpdate(update) { + return update.update.summary.state.status === JobUpdateStatus.ROLLED_FORWARD; +} + +export function isInProgressUpdate(update) { + return update.update.summary.state.status === JobUpdateStatus.ROLLING_FORWARD; +} + +function processInstanceIdsFromRanges(ranges, fn) { + ranges.forEach((r) => { + for (let i = r.first; i <= r.last; i++) { + fn(i); + } + }); +} + +function getAllInstanceIds(update) { + const allIds = {}; + const newIds = {}; + const oldIds = {}; + + processInstanceIdsFromRanges(update.instructions.desiredState.instances, (id) => { + newIds[id] = true; + allIds[id] = true; + }); + + update.instructions.initialState.forEach((task) => { + processInstanceIdsFromRanges(task.instances, (id) => { + oldIds[id] = true; + allIds[id] = true; + }); + }); + return { allIds, newIds, oldIds }; +} + +function getLatestInstanceEvents(instanceEvents, predicate = (e) => true) { + const events = sort(instanceEvents, (e) => e.timestampMs, true); + const instanceMap = {}; + events.forEach((e) => { + if (!instanceMap.hasOwnProperty(e.instanceId) && predicate(e)) { + instanceMap[e.instanceId] = e; + } + }); + return instanceMap; +} + +export function instanceSummary(details) { + const instances = getAllInstanceIds(details.update); + const latestInstanceEvents = getLatestInstanceEvents(details.instanceEvents); + const allIds = Object.keys(instances.allIds); + + return allIds.map((i) => { + // If there is an event, use the event to generate the instance status. + if (latestInstanceEvents.hasOwnProperty(i)) { + const latestEvent = latestInstanceEvents[i]; + // If instance has been updated and is in initial state, but not in desired state, + // then it's a removed instance. + if (latestEvent.action === JobUpdateAction.INSTANCE_UPDATED && + instances.oldIds.hasOwnProperty(i) && + !instances.newIds.hasOwnProperty(i)) { + return { + instanceId: i, + className: 'removed', + title: 'Instance Removed' + }; + } + + // Normal case - the latest action is the current status + return { + instanceId: i, + className: getClassForUpdateAction(latestEvent.action), + title: UPDATE_ACTION[latestEvent.action] + }; + } else { + // No event, so it's a pending instance. + return { + instanceId: i, + className: 'pending', + title: 'Pending' + }; + } + }); +} + +function progressFromEvents(instanceEvents) { + const success = getLatestInstanceEvents(instanceEvents, + (e) => e.action === JobUpdateAction.INSTANCE_UPDATED); + return Object.keys(success).length; +} + +export function updateStats(details) { + const allInstances = Object.keys(getAllInstanceIds(details.update).allIds); + const totalInstancesToBeUpdated = allInstances.length; + const instancesUpdated = progressFromEvents(details.instanceEvents); + const progress = Math.round((instancesUpdated / totalInstancesToBeUpdated) * 100); + return { + totalInstancesToBeUpdated, + instancesUpdated, + progress + }; +} + +export function getInProgressStates() { + return ACTIVE_JOB_UPDATE_STATES; +} + +export function getTerminalStates() { + const active = new Set(ACTIVE_JOB_UPDATE_STATES); + return Object.values(JobUpdateStatus).filter((k) => !active.has(k)); +} + +export function getClassForUpdateStatus(status) { + if (Thrift.OKAY_UPDATE_STATUS.includes(status)) { + return 'okay'; + } else if (Thrift.WARNING_UPDATE_STATUS.includes(status)) { + return 'attention'; + } else if (Thrift.ERROR_UPDATE_STATUS.includes(status)) { + return 'error'; + } + return 'in-progress'; +} + +export function getClassForUpdateAction(action) { + if (Thrift.OKAY_UPDATE_ACTION.includes(action)) { + return 'okay'; + } else if (Thrift.WARNING_UPDATE_ACTION.includes(action)) { + return 'attention'; + } else if (Thrift.ERROR_UPDATE_ACTION.includes(action)) { + return 'error'; + } + return 'in-progress'; +} + +export function statusDispatcher(dispatch) { + return (update) => { + const status = update.update.summary.state.status; + if (Thrift.OKAY_UPDATE_STATUS.includes(status)) { + return dispatch.success(update); + } else if (Thrift.WARNING_UPDATE_STATUS.includes(status)) { + return dispatch.warning(update); + } else if (Thrift.ERROR_UPDATE_STATUS.includes(status)) { + return dispatch.error(update); + } + return dispatch.inProgress(update); + }; +} + +export function actionDispatcher(dispatch) { + return (event) => { + const action = event.action; + if (Thrift.OKAY_UPDATE_ACTION.includes(action)) { + return dispatch.success(event); + } else if (Thrift.WARNING_UPDATE_ACTION.includes(action)) { + return dispatch.warning(event); + } else if (Thrift.ERROR_UPDATE_ACTION.includes(action)) { + return dispatch.error(event); + } + return dispatch.inProgress(event); + }; +} http://git-wip-us.apache.org/repos/asf/aurora/blob/4a1fba3c/ui/src/main/js/utils/__tests__/Update-test.js ---------------------------------------------------------------------- diff --git a/ui/src/main/js/utils/__tests__/Update-test.js b/ui/src/main/js/utils/__tests__/Update-test.js new file mode 100644 index 0000000..88fa5f7 --- /dev/null +++ b/ui/src/main/js/utils/__tests__/Update-test.js @@ -0,0 +1,291 @@ +import { + actionDispatcher, + getClassForUpdateStatus, + getClassForUpdateAction, + instanceSummary, + statusDispatcher, + updateStats +} from '../Update'; + +import { + InstanceTaskConfigBuilder, + InstanceUpdateEventBuilder, + UpdateBuilder, + UpdateDetailsBuilder, + UpdateInstructionsBuilder, + builderWithStatus } from 'test-utils/UpdateBuilders'; +import { + ERROR_UPDATE_ACTION, + ERROR_UPDATE_STATUS, + WARNING_UPDATE_ACTION, + WARNING_UPDATE_STATUS } from 'utils/Thrift'; + +function createDispatchSpies() { + return { + error: jest.fn(), + inProgress: jest.fn(), + success: jest.fn(), + warning: jest.fn() + }; +} + +function assertSpies(spies, expect) { + return ['error', 'inProgress', 'success', 'warning'].every((key) => { + const expected = expect[key] || 0; + return spies[key].mock.calls.length === expected; + }); +} + +describe('actionDispatcher', () => { + it('Should dispatch success events', () => { + const spies = createDispatchSpies(); + const dispatcher = actionDispatcher(spies); + dispatcher(InstanceUpdateEventBuilder.action(JobUpdateAction.INSTANCE_UPDATED).build()); + expect(assertSpies(spies, {success: 1})).toBe(true); + }); + + it('Should dispatch in-progress events', () => { + const spies = createDispatchSpies(); + const dispatcher = actionDispatcher(spies); + dispatcher(InstanceUpdateEventBuilder.action(JobUpdateAction.INSTANCE_UPDATING).build()); + expect(assertSpies(spies, {inProgress: 1})).toBe(true); + }); + + it('Should dispatch warning events', () => { + WARNING_UPDATE_ACTION.forEach((action) => { + const spies = createDispatchSpies(); + const dispatcher = actionDispatcher(spies); + dispatcher(InstanceUpdateEventBuilder.action(action).build()); + expect(assertSpies(spies, {warning: 1})).toBe(true); + }); + }); + + it('Should dispatch error events', () => { + ERROR_UPDATE_ACTION.forEach((action) => { + const spies = createDispatchSpies(); + const dispatcher = actionDispatcher(spies); + dispatcher(InstanceUpdateEventBuilder.action(action).build()); + expect(assertSpies(spies, {error: 1})).toBe(true); + }); + }); +}); + +describe('getClassForUpdateAction', () => { + it('Should return okay class', () => { + expect(getClassForUpdateAction(JobUpdateAction.INSTANCE_UPDATED)).toBe('okay'); + }); + + it('Should return in-progress class', () => { + expect(getClassForUpdateAction(JobUpdateAction.INSTANCE_UPDATING)).toBe('in-progress'); + }); + + it('Should dispatch warning events', () => { + WARNING_UPDATE_ACTION.forEach((action) => { + expect(getClassForUpdateAction(action)).toBe('attention'); + }); + }); + + it('Should dispatch error events', () => { + ERROR_UPDATE_ACTION.forEach((action) => { + expect(getClassForUpdateAction(action)).toBe('error'); + }); + }); +}); + +describe('getClassForUpdateStatus', () => { + it('Should return okay for successful updates', () => { + expect(getClassForUpdateStatus(JobUpdateStatus.ROLLED_FORWARD)).toBe('okay'); + }); + + it('Should fire the in-progress callback for rolling forward updates', () => { + expect(getClassForUpdateStatus(JobUpdateStatus.ROLLING_FORWARD)).toBe('in-progress'); + }); + + it('Should fire the error callback for all failed updates', () => { + ERROR_UPDATE_STATUS.forEach((status) => { + expect(getClassForUpdateStatus(status)).toBe('error'); + }); + }); + + it('Should fire the warning callback for all failed updates', () => { + WARNING_UPDATE_STATUS.forEach((status) => { + expect(getClassForUpdateStatus(status)).toBe('attention'); + }); + }); +}); + +describe('instanceSummary', () => { + const instanceUpdated = InstanceUpdateEventBuilder.action(JobUpdateAction.INSTANCE_UPDATED); + + it('Should return the correct data', () => { + const instructions = UpdateInstructionsBuilder + .initialState([InstanceTaskConfigBuilder.instances([{first: 0, last: 10}]).build()]) + .desiredState(InstanceTaskConfigBuilder.instances([{first: 0, last: 9}]).build()) + .build(); + + const update = UpdateDetailsBuilder + .update(UpdateBuilder.instructions(instructions).build()) + .instanceEvents([ + InstanceUpdateEventBuilder.action(JobUpdateAction.INSTANCE_UPDATE_FAILED).build(), + instanceUpdated.instanceId(0).timestampMs(1).build(), + instanceUpdated.instanceId(2).build(), + instanceUpdated.instanceId(3).build(), + instanceUpdated.instanceId(5).build(), + instanceUpdated.instanceId(9).build() + ]) + .build(); + const summary = instanceSummary(update); + expect(summary).toEqual([ + {instanceId: '0', className: 'okay', title: 'INSTANCE_UPDATED'}, + {instanceId: '1', className: 'pending', title: 'Pending'}, + {instanceId: '2', className: 'okay', title: 'INSTANCE_UPDATED'}, + {instanceId: '3', className: 'okay', title: 'INSTANCE_UPDATED'}, + {instanceId: '4', className: 'pending', title: 'Pending'}, + {instanceId: '5', className: 'okay', title: 'INSTANCE_UPDATED'}, + {instanceId: '6', className: 'pending', title: 'Pending'}, + {instanceId: '7', className: 'pending', title: 'Pending'}, + {instanceId: '8', className: 'pending', title: 'Pending'}, + {instanceId: '9', className: 'okay', title: 'INSTANCE_UPDATED'}, + {instanceId: '10', className: 'pending', title: 'Pending'} + ]); + }); + + it('Should handle removed instances', () => { + const instructions = UpdateInstructionsBuilder + .initialState([InstanceTaskConfigBuilder.instances([{first: 0, last: 3}]).build()]) + .desiredState(InstanceTaskConfigBuilder.instances([{first: 0, last: 2}]).build()) + .build(); + + const update = UpdateDetailsBuilder + .update(UpdateBuilder.instructions(instructions).build()) + .instanceEvents([ + InstanceUpdateEventBuilder.action(JobUpdateAction.INSTANCE_UPDATE_FAILED).build(), + InstanceUpdateEventBuilder.instanceId(2) + .action(JobUpdateAction.INSTANCE_ROLLED_BACK) + .build(), + instanceUpdated.instanceId(3).build() + ]) + .build(); + const summary = instanceSummary(update); + expect(summary).toEqual([ + {instanceId: '0', className: 'error', title: 'INSTANCE_UPDATE_FAILED'}, + {instanceId: '1', className: 'pending', title: 'Pending'}, + {instanceId: '2', className: 'attention', title: 'INSTANCE_ROLLED_BACK'}, + {instanceId: '3', className: 'removed', title: 'Instance Removed'} + ]); + }); +}); + +describe('statusDispatcher', () => { + it('Should fire the success callback for successful updates', () => { + const spies = createDispatchSpies(); + const dispatcher = statusDispatcher(spies); + dispatcher(builderWithStatus(JobUpdateStatus.ROLLED_FORWARD).build()); + expect(assertSpies(spies, {success: 1})).toBe(true); + }); + + it('Should fire the in-progress callback for rolling forward updates', () => { + const spies = createDispatchSpies(); + const dispatcher = statusDispatcher(spies); + dispatcher(builderWithStatus(JobUpdateStatus.ROLLING_FORWARD).build()); + expect(assertSpies(spies, {inProgress: 1})).toBe(true); + }); + + it('Should fire the error callback for all failed updates', () => { + ERROR_UPDATE_STATUS.forEach((status) => { + const spies = createDispatchSpies(); + const dispatcher = statusDispatcher(spies); + dispatcher(builderWithStatus(status).build()); + expect(assertSpies(spies, {error: 1})).toBe(true); + }); + }); + + it('Should fire the warning callback for all failed updates', () => { + WARNING_UPDATE_STATUS.forEach((status) => { + const spies = createDispatchSpies(); + const dispatcher = statusDispatcher(spies); + dispatcher(builderWithStatus(status).build()); + expect(assertSpies(spies, {warning: 1})).toBe(true); + }); + }); +}); + +describe('updateStats', () => { + const instanceUpdated = InstanceUpdateEventBuilder.action(JobUpdateAction.INSTANCE_UPDATED); + + it('Should return the correct stats for a job with some instances to be updated', () => { + const instructions = UpdateInstructionsBuilder + .initialState([InstanceTaskConfigBuilder.instances([{first: 0, last: 9}]).build()]) + .desiredState(InstanceTaskConfigBuilder.instances([{first: 0, last: 9}]).build()) + .build(); + + const update = UpdateDetailsBuilder + .update(UpdateBuilder.instructions(instructions).build()) + .instanceEvents([ + InstanceUpdateEventBuilder.action(JobUpdateAction.INSTANCE_UPDATE_FAILED).build(), + instanceUpdated.instanceId(0).build(), + instanceUpdated.instanceId(2).build(), + instanceUpdated.instanceId(3).build(), + instanceUpdated.instanceId(5).build(), + instanceUpdated.instanceId(9).build() + ]) + .build(); + const stats = updateStats(update); + expect(stats).toEqual({totalInstancesToBeUpdated: 10, instancesUpdated: 5, progress: 50}); + }); + + it('Should respect added instances', () => { + const instructions = UpdateInstructionsBuilder + .initialState([InstanceTaskConfigBuilder.instances([{first: 0, last: 4}]).build()]) + .desiredState(InstanceTaskConfigBuilder.instances([{first: 0, last: 9}]).build()) + .build(); + + const update = UpdateDetailsBuilder + .update(UpdateBuilder.instructions(instructions).build()) + .instanceEvents([ + instanceUpdated.instanceId(7).build(), + instanceUpdated.instanceId(8).build() + ]) + .build(); + const stats = updateStats(update); + expect(stats).toEqual({totalInstancesToBeUpdated: 10, instancesUpdated: 2, progress: 20}); + }); + + it('Should respect deleted instances', () => { + const instructions = UpdateInstructionsBuilder + .initialState([InstanceTaskConfigBuilder.instances([{first: 0, last: 9}]).build()]) + .desiredState(InstanceTaskConfigBuilder.instances([{first: 0, last: 4}]).build()) + .build(); + + const update = UpdateDetailsBuilder + .update(UpdateBuilder.instructions(instructions).build()) + .instanceEvents([ + instanceUpdated.instanceId(7).build(), + instanceUpdated.instanceId(8).build() + ]) + .build(); + const stats = updateStats(update); + expect(stats).toEqual({totalInstancesToBeUpdated: 10, instancesUpdated: 2, progress: 20}); + }); + + it('Any instances updated should show up in stats, even if rolled back', () => { + const instructions = UpdateInstructionsBuilder + .initialState([InstanceTaskConfigBuilder.instances([{first: 0, last: 9}]).build()]) + .desiredState(InstanceTaskConfigBuilder.instances([{first: 0, last: 9}]).build()) + .build(); + + const update = UpdateDetailsBuilder + .update(UpdateBuilder.instructions(instructions).build()) + .instanceEvents([ + instanceUpdated.instanceId(0).build(), + instanceUpdated.instanceId(2).build(), + InstanceUpdateEventBuilder.instanceId(2) + .action(JobUpdateAction.INSTANCE_UPDATE_FAILED) + .timestampMs(2) + .build() + ]) + .build(); + const stats = updateStats(update); + expect(stats).toEqual({totalInstancesToBeUpdated: 10, instancesUpdated: 2, progress: 20}); + }); +}); http://git-wip-us.apache.org/repos/asf/aurora/blob/4a1fba3c/ui/src/main/sass/app.scss ---------------------------------------------------------------------- diff --git a/ui/src/main/sass/app.scss b/ui/src/main/sass/app.scss index e301d4c..315b666 100644 --- a/ui/src/main/sass/app.scss +++ b/ui/src/main/sass/app.scss @@ -6,12 +6,15 @@ /* Indiviudal Components */ @import 'components/breadcrumb'; +@import 'components/instance-viz'; @import 'components/navigation'; @import 'components/state-machine'; @import 'components/status'; @import 'components/tables'; +@import 'components/update-list'; /* Page Styles */ @import 'components/home-page'; @import 'components/instance-page'; -@import 'components/job-list-page'; \ No newline at end of file +@import 'components/job-list-page'; +@import 'components/update-page'; \ No newline at end of file http://git-wip-us.apache.org/repos/asf/aurora/blob/4a1fba3c/ui/src/main/sass/components/_instance-viz.scss ---------------------------------------------------------------------- diff --git a/ui/src/main/sass/components/_instance-viz.scss b/ui/src/main/sass/components/_instance-viz.scss new file mode 100644 index 0000000..36a94a6 --- /dev/null +++ b/ui/src/main/sass/components/_instance-viz.scss @@ -0,0 +1,78 @@ +.instance-grid { + list-style-type: none; + margin: 0; + padding: 0; + margin-bottom: 20px; + overflow: hidden; + + li { + padding: 0; + float: left; + margin-right: 2px; + margin-bottom: 2px; + } + + .instance-id { + visibility: hidden; + } + + &:after { + clear: both; + content: '#'; + visibility: hidden; + } + + .okay { + border: 1px solid $colors_success; + background-color: $colors_success_light; + } + + .attention { + border: 1px solid $colors_warning; + background-color: $colors_warning_light; + } + + .error { + border: 1px solid $colors_error; + background-color: $colors_error_light; + } + + .in-progress { + border: 1px solid $colors_highlight; + background-color: $colors_highlight_light; + } + + .removed { + border: 1px solid #444; + background-color: #999; + } + + .instance-updated { + border: 1px solid $colors_success; + background-color: $colors_success_light; + } + + .pending { + border: 1px solid $colors_success; + } + + .instance-updating { + border: 1px solid $success_secondary_color; + background-color: $success_secondary_color; + } +} + +.instance-grid.small li { + width: 3px; + height: 6px; +} + +.instance-grid.medium li { + width: 6px; + height: 12px; +} + +.instance-grid.big li { + width: 15px; + height: 25px; +} \ No newline at end of file http://git-wip-us.apache.org/repos/asf/aurora/blob/4a1fba3c/ui/src/main/sass/components/_layout.scss ---------------------------------------------------------------------- diff --git a/ui/src/main/sass/components/_layout.scss b/ui/src/main/sass/components/_layout.scss index 4ebec05..4818ab2 100644 --- a/ui/src/main/sass/components/_layout.scss +++ b/ui/src/main/sass/components/_layout.scss @@ -21,6 +21,16 @@ font-size: 18px; } + .content-panel-subtitle { + padding: 15px 20px 5px 20px; + background-color: rgba(250, 250, 250, 1); + border-bottom: 1px solid $grid_color; + text-transform: uppercase; + color: #555; + font-size: 14px; + font-weight: 700; + } + .content-panel { padding: 20px; background-color: $content_box_color; @@ -29,6 +39,81 @@ .content-panel + .content-panel { margin-top: 1px; } + + .fluid { + padding: 0px; + } + + .content-icon-title { + background-color: $content_box_color; + + .content-icon-title-text { + background-color: $grid_color; + padding: 15px 10px; + margin-left: -20px; + display: block; + color: white; + + font-weight: $heavy; + text-transform: uppercase; + font-size: 24px; + + .glyphicon { + font-size: 0.8em; + } + } + + &:after { + content: ""; + width: 0; + height: 0; + display: block; + margin-left: -20px; + border-width: 0 20px 12px 0; + border-color: transparent $grid_color transparent transparent; + border-style: solid; + } + } +} + +.success { + .content-icon-title-text { + background-color: $colors_success !important; + } + + &:after { + border-color: transparent $colors_success_dark transparent transparent !important; + } +} + +.error { + .content-icon-title-text { + background-color: $colors_error !important; + } + + &:after { + border-color: transparent $colors_error_dark transparent transparent !important; + } +} + +.highlight { + .content-icon-title-text { + background-color: $colors_highlight !important; + } + + &:after { + border-color: transparent $colors_highlight_dark transparent transparent !important; + } +} + +.attention { + .content-icon-title-text { + background-color: $colors_warning !important; + } + + &:after { + border-color: transparent $colors_warning_dark transparent transparent !important; + } } .content-panel-fluid { http://git-wip-us.apache.org/repos/asf/aurora/blob/4a1fba3c/ui/src/main/sass/components/_update-list.scss ---------------------------------------------------------------------- diff --git a/ui/src/main/sass/components/_update-list.scss b/ui/src/main/sass/components/_update-list.scss new file mode 100644 index 0000000..83a1f5a --- /dev/null +++ b/ui/src/main/sass/components/_update-list.scss @@ -0,0 +1,38 @@ +.update-list { + .update-list-item { + display: flex; + align-items: center; + padding: 10px 20px; + border-bottom: 1px solid $grid_color; + + .img-circle { + margin-right: 10px; + } + + a { + font-size: 16px; + font-weight: 600; + } + + .update-list-item-status { + display: block; + } + + &:hover { + background-color: #edf5fd; + } + + .update-list-user { + font-weight: 600; + } + + .update-list-status { + color: $secondary_font_color; + } + + .update-list-last-updated { + margin-left: auto; + color: $secondary_font_color; + } + } +} http://git-wip-us.apache.org/repos/asf/aurora/blob/4a1fba3c/ui/src/main/sass/components/_update-page.scss ---------------------------------------------------------------------- diff --git a/ui/src/main/sass/components/_update-page.scss b/ui/src/main/sass/components/_update-page.scss new file mode 100644 index 0000000..a0db0b4 --- /dev/null +++ b/ui/src/main/sass/components/_update-page.scss @@ -0,0 +1,151 @@ +.update-page { + pre { + border: 0; + background-color: $content_box_color; + } + + .update-summary-stats { + display: flex; + justify-content: space-between; + align-items: baseline; + text-transform: uppercase; + + h5 { + margin: 0; + font-size: 14px; + } + + span { + font-size: 12px; + } + } + + .update-settings { + width: 100%; + font-size: 14px; + text-transform: uppercase; + + td { + padding: 3px; + } + } + + .update-byline { + margin-top: -15px; + color: $secondary_font_color; + + span + span { + margin: 0px 3px; + } + + span:last-child { + margin: 0; + } + } + + .update-time { + text-align: center; + text-transform: uppercase; + } + + .update-time h4 { + font-size: 33px; + margin: 0; + } + + .update-time .time-ago { + color: #999; + } + + .time-divider { + text-align: center; + font-size: 70px; + font-weight: bold; + color: #ccc; + } + + .time-display-duration { + text-transform: uppercase; + text-align: center; + color: #999; + margin-top: -20px; + margin-bottom: 20px; + } + + .update-duration { + text-align: center; + color: #999; + text-transform: uppercase; + margin-bottom: 20px; + } + + .update-time-range { + display: flex; + justify-content: space-around; + align-items: center; + margin-top: 20px; + + h5 { + color: #CCC; + font-weight: 900; + font-size: 60px; + line-height: 40px; + } + + .update-time, .update-duration { + font-size: 12px; + } + } + + .instance-events { + .glyphicon-chevron-right { + color: $secondary_font_color; + font-size: 12px; + } + + .update-instance-event-id { + font-weight: $heavy; + margin: 0 5px; + display: inline-block; + } + + .update-instance-event-time { + float: right; + color: $secondary_font_color; + display: inline-block; + font-size: 14px; + } + + .update-instance-event-status { + .glyphicon { + margin-left: 5px; + } + + .okay { + color: $colors_success !important; + } + + .attention { + color: $colors_warning !important; + } + + .error { + color: $colors_error !important; + } + + .in-progress { + color: $colors_highlight !important; + } + } + + .update-instance-event-container { + border-bottom: 1px solid $grid_color; + padding: 5px 20px; + + &:hover { + cursor: pointer; + cursor: hand; + } + } + } +} \ No newline at end of file http://git-wip-us.apache.org/repos/asf/aurora/blob/4a1fba3c/ui/test-setup.js ---------------------------------------------------------------------- diff --git a/ui/test-setup.js b/ui/test-setup.js index a403434..a86a89a 100644 --- a/ui/test-setup.js +++ b/ui/test-setup.js @@ -28,4 +28,39 @@ global.ScheduleStatus = { }; global.ACTIVE_STATES = [9,17,6,0,13,12,2,1,16]; -global.TaskQuery = () => {}; \ No newline at end of file +global.TaskQuery = () => {}; +global.JobKey = () => {}; +global.JobUpdateKey = () => {}; +global.JobUpdateQuery = () => {}; + +global.JobUpdateStatus = { + 'ROLLING_FORWARD' : 0, + 'ROLLING_BACK' : 1, + 'ROLL_FORWARD_PAUSED' : 2, + 'ROLL_BACK_PAUSED' : 3, + 'ROLLED_FORWARD' : 4, + 'ROLLED_BACK' : 5, + 'ABORTED' : 6, + 'ERROR' : 7, + 'FAILED' : 8, + 'ROLL_FORWARD_AWAITING_PULSE' : 9, + 'ROLL_BACK_AWAITING_PULSE' : 10 +}; + +global.ACTIVE_JOB_UPDATE_STATES = [ + JobUpdateStatus.ROLLING_FORWARD, + JobUpdateStatus.ROLLING_BACK, + JobUpdateStatus.ROLL_FORWARD_PAUSED, + JobUpdateStatus.ROLL_BACK_PAUSED, + JobUpdateStatus.ROLL_FORWARD_AWAITING_PULSE, + JobUpdateStatus.ROLL_BACK_AWAITING_PULSE +]; + +global.JobUpdateAction = { + 'INSTANCE_UPDATED' : 1, + 'INSTANCE_ROLLED_BACK' : 2, + 'INSTANCE_UPDATING' : 3, + 'INSTANCE_ROLLING_BACK' : 4, + 'INSTANCE_UPDATE_FAILED' : 5, + 'INSTANCE_ROLLBACK_FAILED' : 6 +};
