Repository: aurora Updated Branches: refs/heads/master 519e3df73 -> 2aee90d0e
Implement Job page in React Reviewed at https://reviews.apache.org/r/62908/ Project: http://git-wip-us.apache.org/repos/asf/aurora/repo Commit: http://git-wip-us.apache.org/repos/asf/aurora/commit/2aee90d0 Tree: http://git-wip-us.apache.org/repos/asf/aurora/tree/2aee90d0 Diff: http://git-wip-us.apache.org/repos/asf/aurora/diff/2aee90d0 Branch: refs/heads/master Commit: 2aee90d0e31a43c7d2fd866abd2e14f811d4bc3c Parents: 519e3df Author: David McLaughlin <[email protected]> Authored: Thu Oct 12 14:08:39 2017 -0700 Committer: David McLaughlin <[email protected]> Committed: Thu Oct 12 14:08:39 2017 -0700 ---------------------------------------------------------------------- ui/package.json | 1 + ui/src/main/js/components/Breadcrumb.js | 12 +- ui/src/main/js/components/ConfigDiff.js | 68 ++++++++ ui/src/main/js/components/JobConfig.js | 21 +++ ui/src/main/js/components/Pagination.js | 2 +- ui/src/main/js/components/Tabs.js | 38 +++++ ui/src/main/js/components/TaskConfigSummary.js | 58 +++++++ ui/src/main/js/components/TaskList.js | 78 +++++++++ ui/src/main/js/components/TaskStateMachine.js | 10 ++ ui/src/main/js/components/UpdateList.js | 17 +- ui/src/main/js/components/UpdatePreview.js | 30 ++++ .../js/components/__tests__/Breadcrumb-test.js | 8 +- .../js/components/__tests__/ConfigDiff-test.js | 75 +++++++++ .../js/components/__tests__/JobConfig-test.js | 27 ++++ .../main/js/components/__tests__/Tabs-test.js | 39 +++++ .../js/components/__tests__/TaskList-test.js | 22 +++ ui/src/main/js/index.js | 3 +- ui/src/main/js/pages/Job.js | 146 +++++++++++++++++ ui/src/main/js/pages/__tests__/Job-test.js | 101 ++++++++++++ ui/src/main/js/test-utils/TaskBuilders.js | 7 + ui/src/main/js/utils/Task.js | 23 +++ ui/src/main/sass/app.scss | 2 + ui/src/main/sass/components/_job-page.scss | 158 +++++++++++++++++++ ui/src/main/sass/components/_task-list.scss | 91 +++++++++++ 24 files changed, 1023 insertions(+), 14 deletions(-) ---------------------------------------------------------------------- http://git-wip-us.apache.org/repos/asf/aurora/blob/2aee90d0/ui/package.json ---------------------------------------------------------------------- diff --git a/ui/package.json b/ui/package.json index f4532df..cde8d10 100644 --- a/ui/package.json +++ b/ui/package.json @@ -4,6 +4,7 @@ "description": "UI project for Apache Aurora", "main": "index.js", "dependencies": { + "diff": "^3.4.0", "es6-shim": "^0.35.3", "moment": "^2.18.1", "react": "^16.0.0", http://git-wip-us.apache.org/repos/asf/aurora/blob/2aee90d0/ui/src/main/js/components/Breadcrumb.js ---------------------------------------------------------------------- diff --git a/ui/src/main/js/components/Breadcrumb.js b/ui/src/main/js/components/Breadcrumb.js index 76c6270..4cd7506 100644 --- a/ui/src/main/js/components/Breadcrumb.js +++ b/ui/src/main/js/components/Breadcrumb.js @@ -6,28 +6,28 @@ function url(...args) { } export default function Breadcrumb({ cluster, role, env, name, instance, update }) { - const crumbs = [<Link key='cluster' to='/scheduler'>{cluster}</Link>]; + const crumbs = [<Link key='cluster' to='/beta/scheduler'>{cluster}</Link>]; if (role) { crumbs.push(<span key='role-divider'>/</span>); - crumbs.push(<Link key='role' to={`/scheduler/${url(role)}`}>{role}</Link>); + crumbs.push(<Link key='role' to={`/beta/scheduler/${url(role)}`}>{role}</Link>); } if (env) { crumbs.push(<span key='env-divider'>/</span>); - crumbs.push(<Link key='env' to={`/scheduler/${url(role, env)}`}>{env}</Link>); + crumbs.push(<Link key='env' to={`/beta/scheduler/${url(role, env)}`}>{env}</Link>); } if (name) { crumbs.push(<span key='name-divider'>/</span>); - crumbs.push(<Link key='name' to={`/scheduler/${url(role, env, name)}`}>{name}</Link>); + crumbs.push(<Link key='name' to={`/beta/scheduler/${url(role, env, name)}`}>{name}</Link>); } if (instance) { crumbs.push(<span key='instance-divider'>/</span>); - crumbs.push(<Link key='instance' to={`/scheduler/${url(role, env, name, instance)}`}> + crumbs.push(<Link key='instance' to={`/beta/scheduler/${url(role, env, name, instance)}`}> {instance} </Link>); } if (update) { crumbs.push(<span key='update-divider'>/</span>); - crumbs.push(<Link key='update' to={`/scheduler/${url(role, env, name, 'update', update)}`}> + crumbs.push(<Link key='update' to={`/beta/scheduler/${url(role, env, name, 'update', update)}`}> {update} </Link>); } http://git-wip-us.apache.org/repos/asf/aurora/blob/2aee90d0/ui/src/main/js/components/ConfigDiff.js ---------------------------------------------------------------------- diff --git a/ui/src/main/js/components/ConfigDiff.js b/ui/src/main/js/components/ConfigDiff.js new file mode 100644 index 0000000..9627751 --- /dev/null +++ b/ui/src/main/js/components/ConfigDiff.js @@ -0,0 +1,68 @@ +import React from 'react'; +import { diffJson } from 'diff'; + +import { instanceRangeToString } from 'utils/Task'; + +export default class ConfigDiff extends React.Component { + constructor(props) { + super(props); + this.state = { + leftGroupIdx: 0, + rightGroupIdx: 1 + }; + } + + getPicker(key) { + const that = this; + const group = this.props.groups[this.state[key]]; + if (this.props.groups.length === 2) { + return (<span> + Instances {instanceRangeToString(group.instances)} + </span>); + } else { + const otherOptions = this.props.groups + .map((g, i) => i) + .filter((i) => i !== that.state.leftGroupIdx && i !== that.state.rightGroupIdx); + return (<span> + Instances <select onChange={(e) => this.setState({[key]: parseInt(e.target.value, 10)})}> + <option key='current'>{instanceRangeToString(group.instances)}</option> + {otherOptions.map((i) => (<option key={i} value={i}> + {instanceRangeToString(this.props.groups[i].instances)} + </option>))} + </select> + </span>); + } + } + + diffNavigation() { + if (this.props.groups.length < 2) { + return <div>No configuration.</div>; + } else { + return (<div className='diff-picker'> + Config Diff for <span className='diff-before'> + {this.getPicker('leftGroupIdx')} + </span> and <span className='diff-after'> + {this.getPicker('rightGroupIdx')} + </span> + </div>); + } + } + + render() { + if (this.props.groups.length < 2) { + return <div />; + } + const result = diffJson( + this.props.groups[this.state.leftGroupIdx].config, + this.props.groups[this.state.rightGroupIdx].config); + return (<div className='task-diff'> + {this.diffNavigation()} + <div className='diff-view'> + {result.map((r, i) => ( + <span className={r.added ? 'added' : r.removed ? 'removed' : 'same'} key={i}> + {r.value} + </span>))} + </div> + </div>); + } +} http://git-wip-us.apache.org/repos/asf/aurora/blob/2aee90d0/ui/src/main/js/components/JobConfig.js ---------------------------------------------------------------------- diff --git a/ui/src/main/js/components/JobConfig.js b/ui/src/main/js/components/JobConfig.js new file mode 100644 index 0000000..275f46a --- /dev/null +++ b/ui/src/main/js/components/JobConfig.js @@ -0,0 +1,21 @@ +import React from 'react'; + +import ConfigDiff from 'components/ConfigDiff'; +import Loading from 'components/Loading'; +import TaskConfigSummary from 'components/TaskConfigSummary'; + +import { isNully, sort } from 'utils/Common'; + +export default function JobConfig({ groups }) { + if (isNully(groups)) { + return <Loading />; + } + + const sorted = sort(groups, (g) => g.instances[0].first); + return (<div className='job-configuration'> + <div className='job-configuration-summaries'> + {sorted.map((group, i) => <TaskConfigSummary key={i} {...group} />)} + </div> + <ConfigDiff groups={sorted} /> + </div>); +} http://git-wip-us.apache.org/repos/asf/aurora/blob/2aee90d0/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 0aac09d..6a8b73e 100644 --- a/ui/src/main/js/components/Pagination.js +++ b/ui/src/main/js/components/Pagination.js @@ -104,7 +104,7 @@ export default class Pagination extends React.Component { if (isTable) { return (<tbody> {elements} - <tr className='pagination-row'><td colSpan='100%'>{pagination}</td></tr> + {pagination ? <tr className='pagination-row'><td colSpan='100%'>{pagination}</td></tr> : ''} </tbody>); } return <div>{elements}{pagination}</div>; http://git-wip-us.apache.org/repos/asf/aurora/blob/2aee90d0/ui/src/main/js/components/Tabs.js ---------------------------------------------------------------------- diff --git a/ui/src/main/js/components/Tabs.js b/ui/src/main/js/components/Tabs.js new file mode 100644 index 0000000..43b1950 --- /dev/null +++ b/ui/src/main/js/components/Tabs.js @@ -0,0 +1,38 @@ +import React from 'react'; + +import Icon from 'components/Icon'; + +import { addClass } from 'utils/Common'; + +export default class Tabs extends React.Component { + constructor(props) { + super(props); + this.state = { + active: props.activeTab || props.tabs[0].name + }; + } + + select(name) { + this.setState({active: name}); + } + + render() { + const that = this; + const isActive = (t) => t.name === that.state.active; + return (<div className={addClass('tabs', this.props.className)}> + <ul className='tab-navigation'> + {this.props.tabs.map((t) => ( + <li + className={isActive(t) ? 'active' : ''} + key={t.name} + onClick={(e) => this.select(t.name)}> + {t.icon ? <Icon name={t.icon} /> : ''} + {t.name} + </li>))} + </ul> + <div className='active-tab'> + {this.props.tabs.find(isActive).content} + </div> + </div>); + } +} http://git-wip-us.apache.org/repos/asf/aurora/blob/2aee90d0/ui/src/main/js/components/TaskConfigSummary.js ---------------------------------------------------------------------- diff --git a/ui/src/main/js/components/TaskConfigSummary.js b/ui/src/main/js/components/TaskConfigSummary.js new file mode 100644 index 0000000..69b1a40 --- /dev/null +++ b/ui/src/main/js/components/TaskConfigSummary.js @@ -0,0 +1,58 @@ +import React from 'react'; + +import { constraintToString, getResource, getResources, instanceRangeToString } from 'utils/Task'; + +export default function TaskConfigSummary({ config, instances }) { + return (<table className='table table-bordered task-config-summary'> + <tbody> + <tr> + <th colSpan='100%'> + Configuration for instance {instanceRangeToString(instances)} + </th> + </tr> + <tr> + <th rowSpan='4'>Resources</th> + <td>cpus</td> + <td>{getResource(config.resources, 'numCpus').numCpus}</td> + </tr> + <tr> + <td>ram</td> + <td>{getResource(config.resources, 'ramMb').ramMb}</td> + </tr> + <tr> + <td>disk</td> + <td>{getResource(config.resources, 'diskMb').diskMb}</td> + </tr> + <tr> + <td>ports</td> + <td>{getResources(config.resources, 'namedPort').map((r) => r.namedPort).join(', ')}</td> + </tr> + <tr> + <th>Constraints</th> + <td colSpan='2'> + {config.constraints.map((t) => (<span key={t.name}> + {t.name}: {constraintToString(t.constraint)} + </span>))} + </td> + </tr> + <tr> + <th>Tier</th> + <td colSpan='2'>{config.tier}</td> + </tr> + <tr> + <th>Service</th> + <td colSpan='2'>{config.isService ? 'true' : 'false'}</td> + </tr> + <tr> + <th>Metadata</th> + <td colSpan='2'> + {config.metadata.map((m) => <span key={m.key}>{m.key}: {m.value}</span>)} + </td> + </tr> + <tr> + <th>Contact</th> + <td colSpan='2'>{config.contactEmail}</td> + </tr> + </tbody> + </table>); +} http://git-wip-us.apache.org/repos/asf/aurora/blob/2aee90d0/ui/src/main/js/components/TaskList.js ---------------------------------------------------------------------- diff --git a/ui/src/main/js/components/TaskList.js b/ui/src/main/js/components/TaskList.js new file mode 100644 index 0000000..5a61de8 --- /dev/null +++ b/ui/src/main/js/components/TaskList.js @@ -0,0 +1,78 @@ +import React from 'react'; +import { Link } from 'react-router-dom'; + +import Pagination from 'components/Pagination'; +import { RelativeTime } from 'components/Time'; +import TaskStateMachine from 'components/TaskStateMachine'; + +import { getClassForScheduleStatus, getDuration, getLastEventTime, isActive } from 'utils/Task'; +import { SCHEDULE_STATUS } from 'utils/Thrift'; + +export class TaskListItem extends React.Component { + constructor(props) { + super(props); + this.state = {expand: props.expand || false}; + } + + toggleExpand() { + this.setState({expanded: !this.state.expanded}); + } + + render() { + const task = this.props.task; + const { role, environment, name } = task.assignedTask.task.job; + const latestEvent = task.taskEvents[task.taskEvents.length - 1]; + const active = isActive(task); + const stateMachine = (this.state.expanded) ? <TaskStateMachine task={task} /> : ''; + return (<tr className={this.state.expanded ? 'expanded' : ''}> + <td> + <div className='task-list-item-instance'> + <Link + to={`/beta/scheduler/${role}/${environment}/${name}/${task.assignedTask.instanceId}`}> + {task.assignedTask.instanceId} + </Link> + </div> + </td> + <td className='task-list-item-col'> + <div className='task-list-item'> + <span className='task-list-item-status'> + {SCHEDULE_STATUS[task.status]} + <span className='task-list-item-expander' onClick={(e) => this.toggleExpand()}> + ... + </span> + </span> + <span className={`img-circle ${getClassForScheduleStatus(task.status)}`} /> + <span className='task-list-item-time'> + {active ? 'since' : ''} <RelativeTime ts={getLastEventTime(task)} /> + </span> + {active ? '' + : <span className='task-list-item-duration'>(ran for {getDuration(task)})</span>} + <span className='task-list-item-message'> + {latestEvent.message} + </span> + </div> + {stateMachine} + </td> + <td> + <div className='task-list-item-host'> + <a href={`http://${task.assignedTask.slaveHost}:1338/task/${task.assignedTask.taskId}`}> + {task.assignedTask.slaveHost} + </a> + </div> + </td> + </tr>); + } +} + +export default function TaskList({ tasks }) { + return (<div className='task-list'> + <table className='psuedo-table'> + <Pagination + data={tasks} + hideIfSinglePage + isTable + numberPerPage={25} + renderer={(t) => <TaskListItem key={t.assignedTask.taskId} task={t} />} /> + </table> + </div>); +} http://git-wip-us.apache.org/repos/asf/aurora/blob/2aee90d0/ui/src/main/js/components/TaskStateMachine.js ---------------------------------------------------------------------- diff --git a/ui/src/main/js/components/TaskStateMachine.js b/ui/src/main/js/components/TaskStateMachine.js new file mode 100644 index 0000000..4b1da90 --- /dev/null +++ b/ui/src/main/js/components/TaskStateMachine.js @@ -0,0 +1,10 @@ +import React from 'react'; + +import StateMachine from 'components/StateMachine'; + +import { getClassForScheduleStatus, taskToStateMachine } from 'utils/Task'; + +export default function TaskStateMachine({ task }) { + const states = taskToStateMachine(task); + return <StateMachine className={getClassForScheduleStatus(task.status)} states={states} />; +} http://git-wip-us.apache.org/repos/asf/aurora/blob/2aee90d0/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 index 3f57669..2df2839 100644 --- a/ui/src/main/js/components/UpdateList.js +++ b/ui/src/main/js/components/UpdateList.js @@ -9,8 +9,11 @@ import { isNully } from 'utils/Common'; import { UPDATE_STATUS } from 'utils/Thrift'; import { getClassForUpdateStatus } from 'utils/Update'; -function UpdateListItem({ summary }) { +function UpdateListItem({ summary, titleFn }) { const {job: {role, environment, name}, id} = summary.key; + + const title = titleFn || ((u) => `${role}/${environment}/${name}`); + return (<div className='update-list-item'> <span className={`img-circle ${getClassForUpdateStatus(summary.state.status)}`} /> <div className='update-list-item-details'> @@ -18,7 +21,7 @@ function UpdateListItem({ summary }) { <Link className='update-list-job' to={`/beta/scheduler/${role}/${environment}/${name}/update/${id}`}> - {role}/{environment}/{name} + {title(summary)} </Link> • <span className='update-list-status'> {UPDATE_STATUS[summary.state.status]} </span> @@ -32,6 +35,16 @@ function UpdateListItem({ summary }) { </div>); } +export function JobUpdateList({ updates }) { + if (isNully(updates)) { + return <Loading />; + } + + return (<div className='update-list'> + {updates.map((u) => <UpdateListItem key={u.key.id} summary={u} titleFn={(u) => u.key.id} />)} + </div>); +} + export default function UpdateList({ updates }) { if (isNully(updates)) { return <Loading />; http://git-wip-us.apache.org/repos/asf/aurora/blob/2aee90d0/ui/src/main/js/components/UpdatePreview.js ---------------------------------------------------------------------- diff --git a/ui/src/main/js/components/UpdatePreview.js b/ui/src/main/js/components/UpdatePreview.js new file mode 100644 index 0000000..73dd487 --- /dev/null +++ b/ui/src/main/js/components/UpdatePreview.js @@ -0,0 +1,30 @@ +import React from 'react'; +import { Link } from 'react-router-dom'; + +import PanelGroup, { Container } from 'components/Layout'; +import { RelativeTime } from 'components/Time'; + +import { getClassForUpdateStatus, updateStats } from 'utils/Update'; + +export default function UpdatePreview({ update }) { + const stats = updateStats(update); + const {job: {role, environment, name}, id} = update.update.summary.key; + return (<Container> + <PanelGroup noPadding title=''> + <div + className={`update-preview ${getClassForUpdateStatus(update.update.summary.state.status)}`}> + <Link + to={`/beta/scheduler/${role}/${environment}/${name}/update/${id}`}> + Update In Progress + </Link> + <span className='update-preview-details'> + started by <strong>{update.update.summary.user}</strong> <span> + <RelativeTime ts={update.update.summary.state.createdTimestampMs} /></span> + </span> + <span className='update-preview-progress'> + {stats.instancesUpdated} / {stats.totalInstancesToBeUpdated} ({stats.progress}%) + </span> + </div> + </PanelGroup> + </Container>); +} http://git-wip-us.apache.org/repos/asf/aurora/blob/2aee90d0/ui/src/main/js/components/__tests__/Breadcrumb-test.js ---------------------------------------------------------------------- diff --git a/ui/src/main/js/components/__tests__/Breadcrumb-test.js b/ui/src/main/js/components/__tests__/Breadcrumb-test.js index 47f7afb..0a9ad4b 100644 --- a/ui/src/main/js/components/__tests__/Breadcrumb-test.js +++ b/ui/src/main/js/components/__tests__/Breadcrumb-test.js @@ -8,25 +8,25 @@ import Breadcrumb from '../Breadcrumb'; describe('Breadcrumb', () => { it('Should render cluster crumb', () => { const el = shallow(<Breadcrumb cluster='devcluster' />); - expect(el.contains(<Link to='/scheduler'>devcluster</Link>)).toBe(true); + expect(el.contains(<Link to='/beta/scheduler'>devcluster</Link>)).toBe(true); expect(el.find(Link).length).toBe(1); }); it('Should render role crumb', () => { const el = shallow(<Breadcrumb cluster='devcluster' role='www-data' />); - expect(el.contains(<Link to='/scheduler/www-data'>www-data</Link>)).toBe(true); + expect(el.contains(<Link to='/beta/scheduler/www-data'>www-data</Link>)).toBe(true); expect(el.find(Link).length).toBe(2); }); it('Should render env crumb', () => { const el = shallow(<Breadcrumb cluster='devcluster' env='prod' role='www-data' />); - expect(el.contains(<Link to='/scheduler/www-data/prod'>prod</Link>)).toBe(true); + expect(el.contains(<Link to='/beta/scheduler/www-data/prod'>prod</Link>)).toBe(true); expect(el.find(Link).length).toBe(3); }); it('Should render name crumb', () => { const el = shallow(<Breadcrumb cluster='devcluster' env='prod' name='hello' role='www-data' />); - expect(el.contains(<Link to='/scheduler/www-data/prod/hello'>hello</Link>)).toBe(true); + expect(el.contains(<Link to='/beta/scheduler/www-data/prod/hello'>hello</Link>)).toBe(true); expect(el.find(Link).length).toBe(4); }); }); http://git-wip-us.apache.org/repos/asf/aurora/blob/2aee90d0/ui/src/main/js/components/__tests__/ConfigDiff-test.js ---------------------------------------------------------------------- diff --git a/ui/src/main/js/components/__tests__/ConfigDiff-test.js b/ui/src/main/js/components/__tests__/ConfigDiff-test.js new file mode 100644 index 0000000..3eaa78a --- /dev/null +++ b/ui/src/main/js/components/__tests__/ConfigDiff-test.js @@ -0,0 +1,75 @@ +import React from 'react'; +import { shallow } from 'enzyme'; + +import ConfigDiff from '../ConfigDiff'; + +import { TaskConfigBuilder, createConfigGroup } from 'test-utils/TaskBuilders'; + +describe('ConfigDiff', () => { + it('Should render an empty div when there are less than 2 config groups', () => { + const groups = [createConfigGroup(TaskConfigBuilder, [0, 0])]; + const el = shallow(<ConfigDiff groups={groups} />); + expect(el.contains(<div />)).toBe(true); + }); + + it('Should not add change classes to diff viewer when configs are same', () => { + const groups = [ + createConfigGroup(TaskConfigBuilder, [0, 0]), + createConfigGroup(TaskConfigBuilder, [1, 9]) + ]; + const el = shallow(<ConfigDiff groups={groups} />); + expect(el.find('span.removed').length).toBe(0); + expect(el.find('span.added').length).toBe(0); + }); + + it('Should add change classes to diff viewer when configs are not the same', () => { + const groups = [ + createConfigGroup(TaskConfigBuilder, [0, 0]), + createConfigGroup(TaskConfigBuilder.tier('something-else'), [1, 9]) + ]; + const el = shallow(<ConfigDiff groups={groups} />); + expect(el.find('span.removed').length).toBe(1); + expect(el.find('span.added').length).toBe(1); + }); + + it('Should not show any config group dropdown when there are only two groups', () => { + const groups = [ + createConfigGroup(TaskConfigBuilder, [0, 0]), + createConfigGroup(TaskConfigBuilder.tier('something-else'), [1, 9]) + ]; + const el = shallow(<ConfigDiff groups={groups} />); + expect(el.find('select').length).toBe(0); + }); + + it('Should show a group dropdown when there are more than two groups', () => { + const groups = [ + createConfigGroup(TaskConfigBuilder, [0, 0]), + createConfigGroup(TaskConfigBuilder.tier('something-else'), [1, 1]), + createConfigGroup(TaskConfigBuilder.tier('something-else'), [2, 2]) + ]; + const el = shallow(<ConfigDiff groups={groups} />); + expect(el.find('select').length).toBe(2); + expect(el.find('option').length).toBe(4); + }); + + it('Should update the diff view when you select new groups', () => { + const groups = [ + createConfigGroup(TaskConfigBuilder, [0, 0]), + createConfigGroup(TaskConfigBuilder.tier('something-else'), [1, 1]), + createConfigGroup(TaskConfigBuilder.tier('something-else'), [2, 2]) + ]; + const el = shallow(<ConfigDiff groups={groups} />); + expect(el.find('span.removed').length).toBe(1); + expect(el.find('span.added').length).toBe(1); + + expect(el.find('.diff-before select').length).toBe(1); + expect(el.find('option').length).toBe(4); + + // Change the left config to be index=2, which has the same config as index=1 + el.find('.diff-before select').simulate('change', {target: {value: '2'}}); + // Now assert the diff was updated! + expect(el.find('span.removed').length).toBe(0); + expect(el.find('span.added').length).toBe(0); + expect(el.find('option').length).toBe(4); + }); +}); http://git-wip-us.apache.org/repos/asf/aurora/blob/2aee90d0/ui/src/main/js/components/__tests__/JobConfig-test.js ---------------------------------------------------------------------- diff --git a/ui/src/main/js/components/__tests__/JobConfig-test.js b/ui/src/main/js/components/__tests__/JobConfig-test.js new file mode 100644 index 0000000..59541d9 --- /dev/null +++ b/ui/src/main/js/components/__tests__/JobConfig-test.js @@ -0,0 +1,27 @@ +import React from 'react'; +import { shallow } from 'enzyme'; + +import ConfigDiff from '../ConfigDiff'; +import JobConfig from '../JobConfig'; +import Loading from '../Loading'; +import TaskConfigSummary from '../TaskConfigSummary'; + +import { TaskConfigBuilder, createConfigGroup } from 'test-utils/TaskBuilders'; + +describe('JobConfig', () => { + it('Should render summaries and diff with configs in order of lowest instance id', () => { + const group0 = createConfigGroup(TaskConfigBuilder, [0, 0]); + const group1 = createConfigGroup(TaskConfigBuilder, [1, 9]); + const group2 = createConfigGroup(TaskConfigBuilder, [10, 10]); + + const el = shallow(<JobConfig groups={[group2, group0, group1]} />); + const summaries = el.find(TaskConfigSummary).map((i) => i.props().instances); + expect(summaries).toEqual([group0.instances, group1.instances, group2.instances]); + expect(el.contains(<ConfigDiff groups={[group0, group1, group2]} />)).toBe(true); + }); + + it('Should render Loading when no groups are supplied', () => { + const el = shallow(<JobConfig />); + expect(el.contains(<Loading />)).toBe(true); + }); +}); http://git-wip-us.apache.org/repos/asf/aurora/blob/2aee90d0/ui/src/main/js/components/__tests__/Tabs-test.js ---------------------------------------------------------------------- diff --git a/ui/src/main/js/components/__tests__/Tabs-test.js b/ui/src/main/js/components/__tests__/Tabs-test.js new file mode 100644 index 0000000..e028c2d --- /dev/null +++ b/ui/src/main/js/components/__tests__/Tabs-test.js @@ -0,0 +1,39 @@ +import React from 'react'; +import { shallow } from 'enzyme'; + +import Tabs from '../Tabs'; + +const DummyTab = ({ number }) => <span>Hello, {number}</span>; + +const tabs = [ + {name: 'one', content: <DummyTab number={1} />}, + {name: 'two', content: <DummyTab number={2} />}, + {name: 'three', content: <DummyTab number={3} />} +]; + +describe('Tabs', () => { + it('Should set the first tab to active by default', () => { + const el = shallow(<Tabs tabs={tabs} />); + expect(el.contains(<DummyTab number={1} />)).toBe(true); + expect(el.find(DummyTab).length).toBe(1); + expect(el.find('.active').key()).toBe('one'); + }); + + it('Should allow you to specify a default via props', () => { + const el = shallow(<Tabs activeTab='two' tabs={tabs} />); + expect(el.contains(<DummyTab number={2} />)).toBe(true); + expect(el.find(DummyTab).length).toBe(1); + expect(el.find('.active').key()).toBe('two'); + }); + + it('Should switch tabs on click', () => { + const el = shallow(<Tabs tabs={tabs} />); + expect(el.contains(<DummyTab number={1} />)).toBe(true); + expect(el.find(DummyTab).length).toBe(1); + expect(el.find('.active').key()).toBe('one'); + + el.find('li').at(2).simulate('click'); + expect(el.contains(<DummyTab number={3} />)).toBe(true); + expect(el.find('.active').key()).toBe('three'); + }); +}); http://git-wip-us.apache.org/repos/asf/aurora/blob/2aee90d0/ui/src/main/js/components/__tests__/TaskList-test.js ---------------------------------------------------------------------- diff --git a/ui/src/main/js/components/__tests__/TaskList-test.js b/ui/src/main/js/components/__tests__/TaskList-test.js new file mode 100644 index 0000000..ae74ff4 --- /dev/null +++ b/ui/src/main/js/components/__tests__/TaskList-test.js @@ -0,0 +1,22 @@ +import React from 'react'; +import { shallow } from 'enzyme'; + +import { TaskListItem } from '../TaskList'; +import TaskStateMachine from '../TaskStateMachine'; + +import { ScheduledTaskBuilder } from 'test-utils/TaskBuilders'; + +describe('TaskListItem', () => { + it('Should not show any state machine element by default', () => { + const el = shallow(<TaskListItem task={ScheduledTaskBuilder.build()} />); + expect(el.find(TaskStateMachine).length).toBe(0); + expect(el.find('tr.expanded').length).toBe(0); + }); + + it('Should show the state machine and add expanded to row when expand link is clicked', () => { + const el = shallow(<TaskListItem task={ScheduledTaskBuilder.build()} />); + el.find('.task-list-item-expander').simulate('click'); + expect(el.find(TaskStateMachine).length).toBe(1); + expect(el.find('tr.expanded').length).toBe(1); + }); +}); http://git-wip-us.apache.org/repos/asf/aurora/blob/2aee90d0/ui/src/main/js/index.js ---------------------------------------------------------------------- diff --git a/ui/src/main/js/index.js b/ui/src/main/js/index.js index 30646f7..13d722f 100644 --- a/ui/src/main/js/index.js +++ b/ui/src/main/js/index.js @@ -6,6 +6,7 @@ import SchedulerClient from 'client/scheduler-client'; import Navigation from 'components/Navigation'; import Home from 'pages/Home'; import Instance from 'pages/Instance'; +import Job from 'pages/Job'; import Jobs from 'pages/Jobs'; import Update from 'pages/Update'; import Updates from 'pages/Updates'; @@ -21,7 +22,7 @@ const SchedulerUI = () => ( <Route component={injectApi(Home)} exact path='/beta/scheduler' /> <Route component={injectApi(Jobs)} exact path='/beta/scheduler/:role' /> <Route component={injectApi(Jobs)} exact path='/beta/scheduler/:role/:environment' /> - <Route component={Home} exact path='/beta/scheduler/:role/:environment/:name' /> + <Route component={injectApi(Job)} exact path='/beta/scheduler/:role/:environment/:name' /> <Route component={injectApi(Instance)} exact http://git-wip-us.apache.org/repos/asf/aurora/blob/2aee90d0/ui/src/main/js/pages/Job.js ---------------------------------------------------------------------- diff --git a/ui/src/main/js/pages/Job.js b/ui/src/main/js/pages/Job.js new file mode 100644 index 0000000..fc400f7 --- /dev/null +++ b/ui/src/main/js/pages/Job.js @@ -0,0 +1,146 @@ +import React from 'react'; + +import Breadcrumb from 'components/Breadcrumb'; +import JobConfig from 'components/JobConfig'; +import PanelGroup, { Container, StandardPanelTitle } from 'components/Layout'; +import Loading from 'components/Loading'; +import Tabs from 'components/Tabs'; +import TaskList from 'components/TaskList'; +import { JobUpdateList } from 'components/UpdateList'; +import UpdatePreview from 'components/UpdatePreview'; + +import { isNully, sort } from 'utils/Common'; +import { getLastEventTime, isActive } from 'utils/Task'; +import { isInProgressUpdate } from 'utils/Update'; + +export default class Job extends React.Component { + constructor(props) { + super(props); + this.state = { + cluster: props.cluster || '', + configGroups: props.configGroups, + tasks: props.tasks, + updates: props.updates, + pendingReasons: props.pendingReasons + }; + } + + componentWillMount() { + const {api, match: {params: {role, environment, name}}} = this.props; + const that = this; + const key = new JobKey({role, environment, name}); + + const taskQuery = new TaskQuery(); + taskQuery.role = role; + taskQuery.environment = environment; + taskQuery.jobName = name; + api.getTasksWithoutConfigs(taskQuery, (response) => { + that.setState({ + cluster: response.serverInfo.clusterName, + tasks: response.result.scheduleStatusResult.tasks + }); + }); + api.getPendingReason(taskQuery, (response) => { + that.setState({ + cluster: response.serverInfo.clusterName, + pendingReasons: response.result.getPendingReasonResult.reasons + }); + }); + api.getConfigSummary(key, (response) => { + that.setState({ + cluster: response.serverInfo.clusterName, + configGroups: response.result.configSummaryResult.summary.groups + }); + }); + + const updateQuery = new JobUpdateQuery(); + updateQuery.jobKey = key; + api.getJobUpdateDetails(null, updateQuery, (response) => { + that.setState({ + cluster: response.serverInfo.clusterName, + updates: response.result.getJobUpdateDetailsResult.detailsList + }); + }); + } + + updateInProgress() { + if (!this.state.updates) { + return ''; + } + + const updateInProgress = this.state.updates.find(isInProgressUpdate); + if (!updateInProgress) { + return ''; + } + return <UpdatePreview update={updateInProgress} />; + } + + updateHistory() { + if (!this.state.updates || this.state.updates.length === 0) { + return ''; + } + + const terminalUpdates = this.state.updates + .filter((u) => !isInProgressUpdate(u)) + .map((u) => u.update.summary); + + if (terminalUpdates.length === 0) { + return ''; + } + + return (<Container> + <PanelGroup noPadding title={<StandardPanelTitle title='Update History' />}> + <JobUpdateList updates={terminalUpdates} /> + </PanelGroup> + </Container>); + } + + jobHistoryTab() { + const terminalTasks = sort( + this.state.tasks.filter((t) => !isActive(t)), (t) => getLastEventTime(t), true); + + return { + name: `Job History (${terminalTasks.length})`, + content: <PanelGroup><TaskList tasks={terminalTasks} /></PanelGroup> + }; + } + + jobStatusTab() { + const activeTasks = sort(this.state.tasks.filter(isActive), (t) => t.assignedTask.instanceId); + const numberConfigs = isNully(this.state.configGroups) ? '' : this.state.configGroups.length; + return { + name: 'Job Status', + content: (<PanelGroup> + <Tabs className='task-status-tabs' tabs={[ + {icon: 'th-list', name: 'Tasks', content: <TaskList tasks={activeTasks} />}, + { + icon: 'info-sign', + name: `Configuration (${numberConfigs})`, + content: <JobConfig groups={this.state.configGroups} /> + }]} /> + </PanelGroup>) + }; + } + + jobOverview() { + if (isNully(this.state.tasks)) { + return <Loading />; + } + return <Tabs className='job-overview' tabs={[this.jobStatusTab(), this.jobHistoryTab()]} />; + } + + render() { + return (<div className='job-page'> + <Breadcrumb + cluster={this.state.cluster} + env={this.props.match.params.environment} + name={this.props.match.params.name} + role={this.props.match.params.role} /> + {this.updateInProgress()} + <Container> + {this.jobOverview()} + </Container> + {this.updateHistory()} + </div>); + } +} http://git-wip-us.apache.org/repos/asf/aurora/blob/2aee90d0/ui/src/main/js/pages/__tests__/Job-test.js ---------------------------------------------------------------------- diff --git a/ui/src/main/js/pages/__tests__/Job-test.js b/ui/src/main/js/pages/__tests__/Job-test.js new file mode 100644 index 0000000..4cc76b8 --- /dev/null +++ b/ui/src/main/js/pages/__tests__/Job-test.js @@ -0,0 +1,101 @@ +import React from 'react'; +import { shallow } from 'enzyme'; + +import Job from '../Job'; + +import Breadcrumb from 'components/Breadcrumb'; +import Loading from 'components/Loading'; +import Tabs from 'components/Tabs'; +import { JobUpdateList } from 'components/UpdateList'; +import UpdatePreview from 'components/UpdatePreview'; + +import { ScheduledTaskBuilder } from 'test-utils/TaskBuilders'; +import { builderWithStatus } from 'test-utils/UpdateBuilders'; + +const params = { + role: 'test-role', + environment: 'test-env', + name: 'test-job' +}; + +function apiSpy() { + return { + getTasksWithoutConfigs: jest.fn(), + getPendingReason: jest.fn(), + getConfigSummary: jest.fn(), + getJobUpdateDetails: jest.fn() + }; +} + +describe('Update', () => { + // basic props to force render of all components + const props = (tasks = []) => { + return {api: apiSpy(), cluster: 'test', match: {params: params}, tasks: tasks}; + }; + + it('Should render Loading and fire off calls for data', () => { + const api = apiSpy(); + expect(shallow(<Job api={api} match={{params: params}} />) + .contains(<Loading />)).toBe(true); + Object.keys(api).forEach((apiKey) => expect(api[apiKey].mock.calls.length).toBe(1)); + }); + + it('Should render breadcrumb with correct values', () => { + const el = shallow(<Job api={apiSpy()} cluster='test' match={{params: params}} tasks={[]} />); + expect(el.contains(<Breadcrumb + cluster='test' + env={params.environment} + name={params.name} + role={params.role} />)).toBe(true); + }); + + it('Should show UpdatePreview if in-progress update exists', () => { + const updates = [builderWithStatus(JobUpdateStatus.ROLLING_FORWARD).build()]; + const el = shallow(<Job {...props()} updates={updates} />); + expect(el.contains(<UpdatePreview update={updates[0]} />)).toBe(true); + }); + + it('Should not show UpdatePreview if no in-progress update exists', () => { + const updates = [builderWithStatus(JobUpdateStatus.ROLLED_FORWARD).build()]; + const el = shallow(<Job {...props()} updates={updates} />); + expect(el.find(UpdatePreview).length).toBe(0); + }); + + it('Should render JobUpdateList with any terminal update summaries', () => { + const updates = [ + builderWithStatus(JobUpdateStatus.ROLLING_FORWARD).build(), + builderWithStatus(JobUpdateStatus.ROLLED_FORWARD).build(), + builderWithStatus(JobUpdateStatus.ROLLED_BACK).build() + ]; + const el = shallow(<Job {...props()} updates={updates} />); + expect(el.contains(<JobUpdateList + updates={[updates[1].update.summary, updates[2].update.summary]} />)).toBe(true); + }); + + it('Should not render JobUpdateList if no terminal updates', () => { + const updates = [builderWithStatus(JobUpdateStatus.ROLLING_FORWARD).build()]; + const el = shallow(<Job {...props()} updates={updates} />); + expect(el.find(JobUpdateList).length).toBe(0); + }); + + it('Should render task list with active tasks only on Job Status tab', () => { + const tasks = [ + ScheduledTaskBuilder.status(ScheduleStatus.PENDING).build(), + ScheduledTaskBuilder.status(ScheduleStatus.FINISHED).build() + ]; + const el = shallow(<Job {...props(tasks)} />); + const taskList = el.find(Tabs).props().tabs[0].content.props.children.props.tabs[0].content; + expect(taskList.props.tasks).toEqual([tasks[0]]); + }); + + it('Should render task list with terminal tasks only on Job History tab', () => { + const tasks = [ + ScheduledTaskBuilder.status(ScheduleStatus.PENDING).build(), + ScheduledTaskBuilder.status(ScheduleStatus.FINISHED).build() + ]; + const el = shallow(<Job {...props(tasks)} />); + expect(el.find(Tabs).props().tabs[1].name).toEqual('Job History (1)'); + const taskList = el.find(Tabs).props().tabs[1].content.props.children; + expect(taskList.props.tasks).toEqual([tasks[1]]); + }); +}); http://git-wip-us.apache.org/repos/asf/aurora/blob/2aee90d0/ui/src/main/js/test-utils/TaskBuilders.js ---------------------------------------------------------------------- diff --git a/ui/src/main/js/test-utils/TaskBuilders.js b/ui/src/main/js/test-utils/TaskBuilders.js index 35b9152..8427722 100644 --- a/ui/src/main/js/test-utils/TaskBuilders.js +++ b/ui/src/main/js/test-utils/TaskBuilders.js @@ -55,3 +55,10 @@ export const ScheduledTaskBuilder = createBuilder({ taskEvents: [TaskEventBuilder.build()], ancestorId: '' }); + +export function createConfigGroup(taskBuilder, ...instances) { + return { + config: taskBuilder.build(), + instances: instances.map((pair) => { return {first: pair[0], last: pair[1]}; }) + }; +} http://git-wip-us.apache.org/repos/asf/aurora/blob/2aee90d0/ui/src/main/js/utils/Task.js ---------------------------------------------------------------------- diff --git a/ui/src/main/js/utils/Task.js b/ui/src/main/js/utils/Task.js index c58ff7d..3259623 100644 --- a/ui/src/main/js/utils/Task.js +++ b/ui/src/main/js/utils/Task.js @@ -1,4 +1,5 @@ import moment from 'moment'; +import { isNully } from 'utils/Common'; import ThriftUtils, { SCHEDULE_STATUS } from 'utils/Thrift'; export function isActive(task) { @@ -41,3 +42,25 @@ export function getDuration(task) { const latestEvent = moment(task.taskEvents[task.taskEvents.length - 1].timestamp); return moment.duration(latestEvent.diff(firstEvent)).humanize(); } + +export function instanceRangeToString(ranges) { + return ranges.map(({first, last}) => (first === last) ? first : `${first} - ${last}`); +} + +export function getActiveResource(resource) { + return Object.keys(resource).find((r) => !isNully(resource[r])); +} + +export function constraintToString(constraint) { + return isNully(constraint.value) + ? `limit=${constraint.limit.limit}` + : constraint.value.values.join(','); +} + +export function getResource(resources, key) { + return resources.find((r) => !isNully(r[key])); +} + +export function getResources(resources, key) { + return resources.filter((r) => !isNully(r[key])); +} http://git-wip-us.apache.org/repos/asf/aurora/blob/2aee90d0/ui/src/main/sass/app.scss ---------------------------------------------------------------------- diff --git a/ui/src/main/sass/app.scss b/ui/src/main/sass/app.scss index 315b666..3a799b6 100644 --- a/ui/src/main/sass/app.scss +++ b/ui/src/main/sass/app.scss @@ -11,10 +11,12 @@ @import 'components/state-machine'; @import 'components/status'; @import 'components/tables'; +@import 'components/task-list'; @import 'components/update-list'; /* Page Styles */ @import 'components/home-page'; @import 'components/instance-page'; +@import 'components/job-page'; @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/2aee90d0/ui/src/main/sass/components/_job-page.scss ---------------------------------------------------------------------- diff --git a/ui/src/main/sass/components/_job-page.scss b/ui/src/main/sass/components/_job-page.scss new file mode 100644 index 0000000..8523fcf --- /dev/null +++ b/ui/src/main/sass/components/_job-page.scss @@ -0,0 +1,158 @@ +.job-page { + .job-overview { + .tab-navigation { + background-color: rgba(0, 0, 0, 0.01); + list-style-type: none; + padding: 0; + margin: 0; + + li { + cursor: hand; + cursor: pointer; + font-weight: 700; + padding: 15px 20px; + font-size: 18px; + color: $secondary_font_color; + display: inline-block; + } + + li.active { + cursor: default; + color: #555; + background-color: $content_box_color; + } + } + + .content-panel-group { + margin: 0 !important; + } + } + + .task-status-tabs { + .tab-navigation { + border: 0; + background-color: $content_box_color; + margin-bottom: 10px; + + .glyphicon { + font-size: 0.8em; + margin-right: 5px; + } + + li { + padding: 5px 15px 0px 15px; + text-transform: uppercase; + font-size: 14px; + } + + li.active { + color: steelblue; + border: 0; + } + } + } + + .job-configuration-summaries { + display: flex; + flex-wrap: wrap; + font-size: 13px; + + .task-config-summary { + width: 350px; + border: 1px solid $grid_color; + margin-right: 20px; + margin-bottom: 20px; + } + + tr:first-child { + background-color: $grid_color; + } + + th { + text-transform: uppercase; + } + } + + .task-diff { + font-family: 'Courier', sans-serif; + font-size: 12px; + border: 1px solid $grid_highlight_color; + + .diff-picker { + font-family: $font_stack; + background-color: $grid_color; + border-bottom: 1px solid $grid_highlight_color; + text-transform: uppercase; + padding: 15px 20px 5px 20px; + color: #555; + font-size: 14px; + font-weight: 700; + + div { + font-weight: 700; + margin: 0px 10px; + } + } + + .diff-view { + span { + display: block; + padding: 2px 10px; + width: 100%; + background-color: $content_box_color; + } + + span.same { + background-color: rgba(0, 0, 0, 0.01); + } + + span.removed { + background-color: $colors_error_light; + } + + span.added { + background-color: $colors_success_light; + } + } + } + + .update-preview { + padding: 20px; + color: white; + align-items: center; + display: flex; + justify-content: space-between; + + &.in-progress { + background-color: $colors_highlight; + } + + &.attention { + background-color: $colors_warning; + } + + &.okay { + background-color: $colors_success; + } + + &.error { + background-color: $colors_error; + } + + a { + color: white; + font-size: 30px; + text-transform: uppercase; + font-weight: 700; + } + + .update-preview-details { + text-transform: uppercase; + } + + .update-preview-progress { + font-weight: 700; + font-size: 24px; + } + } +} \ No newline at end of file http://git-wip-us.apache.org/repos/asf/aurora/blob/2aee90d0/ui/src/main/sass/components/_task-list.scss ---------------------------------------------------------------------- diff --git a/ui/src/main/sass/components/_task-list.scss b/ui/src/main/sass/components/_task-list.scss new file mode 100644 index 0000000..a6e2f0a --- /dev/null +++ b/ui/src/main/sass/components/_task-list.scss @@ -0,0 +1,91 @@ +.task-list { + margin: 10px 0; + border: 1px solid $grid_color !important; + + table { + font-size: 14px; + margin: 0; + border: 0; + + tr:first-child { + border-top: 0; + } + + tr:last-child { + border-bottom: 0; + } + } + + .task-list-item-col { + width: 99%; + } + + td { + padding: 5px; + } + + td:first-child { + text-align: center; + } + + .expanded:hover { + background-color: $content_box_color !important; + } + + .task-list-item-instance { + padding: 5px; + a { + font-size: 20px; + font-weight: 800 !important; + } + } + + .task-list-item-expander { + margin: 0px 5px; + font-weight: 800; + line-height: 10px; + font-size: 14px; + cursor: hand; + cursor: pointer; + text-decoration: underline; + } + + .task-list-item-host { + white-space: nowrap; + margin: 0px 5px; + } + + .img-circle { + margin: 0px 5px; + width: 6px; + height: 6px; + } + + .task-list-item { + align-items: center; + padding: 10px 0; + + .task-list-item-time { + color: $secondary_font_color; + } + + .task-list-item-duration { + color: $secondary_font_color; + margin-left: 5px; + } + + .task-list-item-status { + font-weight: 600; + } + + .task-list-item-message { + display: block; + max-width: 500px; + font-size: 12px; + white-space: nowrap; + max-width: 500px; + text-overflow: ellipsis; + overflow: hidden; + } + } +} \ No newline at end of file
