Repository: aurora Updated Branches: refs/heads/master 0169b8198 -> 8e8e83036
Implement Instance pages in React Reviewed at https://reviews.apache.org/r/62720/ Project: http://git-wip-us.apache.org/repos/asf/aurora/repo Commit: http://git-wip-us.apache.org/repos/asf/aurora/commit/8e8e8303 Tree: http://git-wip-us.apache.org/repos/asf/aurora/tree/8e8e8303 Diff: http://git-wip-us.apache.org/repos/asf/aurora/diff/8e8e8303 Branch: refs/heads/master Commit: 8e8e830365fac902c21bb7b5a985779dbaa60854 Parents: 0169b81 Author: David McLaughlin <[email protected]> Authored: Tue Oct 10 08:53:32 2017 -0700 Committer: David McLaughlin <[email protected]> Committed: Tue Oct 10 08:53:32 2017 -0700 ---------------------------------------------------------------------- ui/.eslintrc | 7 +- ui/package.json | 1 + ui/src/main/js/components/InstanceHistory.js | 20 ++++ .../main/js/components/InstanceHistoryItem.js | 76 +++++++++++++ ui/src/main/js/components/Layout.js | 22 +++- ui/src/main/js/components/StateMachine.js | 40 +++++++ ui/src/main/js/components/TaskDetails.js | 18 +++ ui/src/main/js/components/TaskStatus.js | 33 ++++++ .../__tests__/InstanceHistory-test.js | 24 ++++ .../__tests__/InstanceHistoryItem-test.js | 63 ++++++++++ .../components/__tests__/StateMachine-test.js | 25 ++++ ui/src/main/js/index.js | 6 +- ui/src/main/js/pages/Instance.js | 53 +++++++++ ui/src/main/js/pages/__tests__/Instance-test.js | 61 ++++++++++ ui/src/main/js/test-utils/Builder.js | 31 +++++ ui/src/main/js/test-utils/TaskBuilders.js | 57 ++++++++++ .../js/test-utils/__tests__/Builder-test.js | 32 ++++++ ui/src/main/js/utils/Common.js | 19 ++++ ui/src/main/js/utils/Task.js | 43 +++++++ ui/src/main/js/utils/Thrift.js | 33 ++++++ ui/src/main/sass/app.scss | 3 + ui/src/main/sass/components/_instance-page.scss | 111 ++++++++++++++++++ ui/src/main/sass/components/_job-list-page.scss | 8 -- ui/src/main/sass/components/_layout.scss | 33 ++++++ ui/src/main/sass/components/_state-machine.scss | 114 +++++++++++++++++++ ui/src/main/sass/components/_status.scss | 23 ++++ ui/test-setup.js | 26 +++++ 27 files changed, 969 insertions(+), 13 deletions(-) ---------------------------------------------------------------------- http://git-wip-us.apache.org/repos/asf/aurora/blob/8e8e8303/ui/.eslintrc ---------------------------------------------------------------------- diff --git a/ui/.eslintrc b/ui/.eslintrc index 8d37c60..84a6d37 100644 --- a/ui/.eslintrc +++ b/ui/.eslintrc @@ -6,11 +6,14 @@ "standard-react" ], "env": { - "jasmine": true + "jest": true }, "globals": { + "ACTIVE_STATES": true, "Thrift": true, - "ReadOnlySchedulerClient" + "ReadOnlySchedulerClient": true, + "ScheduleStatus": true, + "TaskQuery": true }, "plugins": [ "chai-friendly" http://git-wip-us.apache.org/repos/asf/aurora/blob/8e8e8303/ui/package.json ---------------------------------------------------------------------- diff --git a/ui/package.json b/ui/package.json index fe0397a..6e8ad7a 100644 --- a/ui/package.json +++ b/ui/package.json @@ -4,6 +4,7 @@ "description": "UI project for Apache Aurora", "main": "index.js", "dependencies": { + "moment": "^2.18.1", "react": "^16.0.0", "react-dom": "^16.0.0", "react-router-dom": "^4.2.2" http://git-wip-us.apache.org/repos/asf/aurora/blob/8e8e8303/ui/src/main/js/components/InstanceHistory.js ---------------------------------------------------------------------- diff --git a/ui/src/main/js/components/InstanceHistory.js b/ui/src/main/js/components/InstanceHistory.js new file mode 100644 index 0000000..fb06390 --- /dev/null +++ b/ui/src/main/js/components/InstanceHistory.js @@ -0,0 +1,20 @@ +import React from 'react'; + +import InstanceHistoryItem from 'components/InstanceHistoryItem'; +import PanelGroup, { Container, StandardPanelTitle } from 'components/Layout'; + +import { getLastEventTime } from 'utils/Task'; + +export default function InstanceHistory({ tasks }) { + const sortedTasks = tasks.sort((a, b) => { + return getLastEventTime(a) > getLastEventTime(b) ? -1 : 1; + }); + + return (<Container className='instance-history'> + <PanelGroup noPadding title={<StandardPanelTitle title='Instance History' />}> + {sortedTasks.length > 0 + ? sortedTasks.map((t) => <InstanceHistoryItem key={t.assignedTask.taskId} task={t} />) + : <div>No task history found.</div>} + </PanelGroup> + </Container>); +} http://git-wip-us.apache.org/repos/asf/aurora/blob/8e8e8303/ui/src/main/js/components/InstanceHistoryItem.js ---------------------------------------------------------------------- diff --git a/ui/src/main/js/components/InstanceHistoryItem.js b/ui/src/main/js/components/InstanceHistoryItem.js new file mode 100644 index 0000000..62d8184 --- /dev/null +++ b/ui/src/main/js/components/InstanceHistoryItem.js @@ -0,0 +1,76 @@ +import moment from 'moment'; +import React from 'react'; + +import Icon from 'components/Icon'; +import StateMachine from 'components/StateMachine'; + +import { + getClassForScheduleStatus, + getDuration, + taskToStateMachine +} from 'utils/Task'; +import { SCHEDULE_STATUS } from 'utils/Thrift'; + +export function InstanceHistoryBody({ task }) { + const states = taskToStateMachine(task); + return [ + <div className='instance-history-item-body'> + <StateMachine + className={getClassForScheduleStatus(task.status)} + states={states} /> + </div>, + <div className='instance-history-item-footer'> + <span><strong>Task ID</strong> {task.assignedTask.taskId}</span> + </div> + ]; +} + +export function InstanceHistoryHeader({ task, toggle }) { + const latestEvent = task.taskEvents[task.taskEvents.length - 1]; + return (<div className='instance-history-item'> + <span className={`img-circle ${getClassForScheduleStatus(task.status)}`} /> + <div className='instance-history-item-details' onClick={toggle}> + <div className='instance-history-status'> + <h5>{SCHEDULE_STATUS[task.status]}</h5> + <span className='instance-history-time'> + <span>{moment(latestEvent.timestamp).fromNow()}</span> + <span> • </span> + <span>Running duration: {getDuration(task)}</span> + </span> + </div> + <div> + <span className='instance-history-message'>{latestEvent.message}</span> + </div> + </div> + <ul className='instance-history-item-actions'> + <li><a href={`http://${task.assignedTask.slaveHost}:1338/task/${task.assignedTask.taskId}`}> + {task.assignedTask.slaveHost} + </a></li> + <li> + <a + className='tip' + data-tip='View task config' + href={`/structdump/task/${task.assignedTask.taskId}`}> + <Icon name='info-sign' /> + </a> + </li> + </ul> + </div>); +} + +export default class InstanceHistoryItem extends React.Component { + constructor(props) { + super(props); + this.state = {expanded: props.expanded || false}; + this._toggle = this.toggle.bind(this); + } + + toggle() { + this.setState({expanded: !this.state.expanded}); + } + + render() { + const body = this.state.expanded ? <InstanceHistoryBody task={this.props.task} /> : ''; + return <div><InstanceHistoryHeader task={this.props.task} toggle={this._toggle} />{body}</div>; + } +} http://git-wip-us.apache.org/repos/asf/aurora/blob/8e8e8303/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 4ca54e3..50d63e6 100644 --- a/ui/src/main/js/components/Layout.js +++ b/ui/src/main/js/components/Layout.js @@ -1,5 +1,7 @@ import React from 'react'; +import { addClass } from 'utils/Common'; + function ContentPanel({ children }) { return <div className='content-panel'>{children}</div>; } @@ -8,9 +10,25 @@ export function StandardPanelTitle({ title }) { return <div className='content-panel-title'>{title}</div>; } -export default function PanelGroup({ children, title }) { - return (<div className='content-panel-group'> +export default function PanelGroup({ children, title, noPadding }) { + const extraClass = noPadding ? ' content-panel-fluid' : ''; + return (<div className={addClass('content-panel-group', extraClass)}> {title} {React.Children.map(children, (p) => <ContentPanel>{p}</ContentPanel>)} </div>); } + +export function PanelRow({ children }) { + return (<div className='flex-row'> + {children} + </div>); +} + +export function Container({ children, className }) { + const width = 12 / children.length; + return (<div className={addClass('container', className)}> + <div className='row'> + {React.Children.map(children, (c) => <div className={`col-md-${width}`}>{c}</div>)} + </div> + </div>); +} http://git-wip-us.apache.org/repos/asf/aurora/blob/8e8e8303/ui/src/main/js/components/StateMachine.js ---------------------------------------------------------------------- diff --git a/ui/src/main/js/components/StateMachine.js b/ui/src/main/js/components/StateMachine.js new file mode 100644 index 0000000..2da85f7 --- /dev/null +++ b/ui/src/main/js/components/StateMachine.js @@ -0,0 +1,40 @@ +import React from 'react'; +import moment from 'moment'; + +function StateItem({ className, state, message, timestamp }) { + return (<li className={className}> + <div className='state-machine-item'> + <svg><circle className='state-machine-bullet' cx={6} cy={6} r={5} /></svg> + <div className='state-machine-item-details'> + <span className='state-machine-item-state'>{state}</span> + <span className='state-machine-item-time'> + {moment(timestamp).utc().format('MM/DD HH:mm:ss') + ' UTC'}<br /> + ({moment(timestamp).fromNow()}) + </span> + <span className='state-machine-item-message'>{message}</span> + </div> + </div> + </li>); +} + +export class StateMachineToggle extends React.Component { + constructor(props) { + super(props); + this.state = { expanded: this.props.expanded || false }; + } + + render() { + const states = this.state.expanded ? this.props.states : [this.props.toggleState]; + return (<div onClick={(e) => this.setState({expanded: !this.state.expanded})}> + <StateMachine className={this.props.className} states={states} /> + </div>); + } +} + +export default function StateMachine({ className, states }) { + return (<div className='state-machine'> + <ul className={className}> + {states.map((s, i) => <StateItem key={i} {...s} />)} + </ul> + </div>); +} http://git-wip-us.apache.org/repos/asf/aurora/blob/8e8e8303/ui/src/main/js/components/TaskDetails.js ---------------------------------------------------------------------- diff --git a/ui/src/main/js/components/TaskDetails.js b/ui/src/main/js/components/TaskDetails.js new file mode 100644 index 0000000..e3a6c9c --- /dev/null +++ b/ui/src/main/js/components/TaskDetails.js @@ -0,0 +1,18 @@ +import React from 'react'; + +export default function TaskDetails({ task }) { + return (<div className='active-task-details'> + <div> + <h5>Task ID</h5> + <span className='debug-data'>{task.assignedTask.taskId}</span> + <a href={`/structdump/task/${task.assignedTask.taskId}`}>view raw config</a> + </div> + <div> + <h5>Host</h5> + <span className='debug-data'>{task.assignedTask.slaveHost}</span> + <a href={`http://${task.assignedTask.slaveHost}:1338/task/${task.assignedTask.taskId}`}> + view sandbox + </a> + </div> + </div>); +} http://git-wip-us.apache.org/repos/asf/aurora/blob/8e8e8303/ui/src/main/js/components/TaskStatus.js ---------------------------------------------------------------------- diff --git a/ui/src/main/js/components/TaskStatus.js b/ui/src/main/js/components/TaskStatus.js new file mode 100644 index 0000000..b514918 --- /dev/null +++ b/ui/src/main/js/components/TaskStatus.js @@ -0,0 +1,33 @@ +import React from 'react'; + +import PanelGroup, { Container, StandardPanelTitle } from 'components/Layout'; +import StateMachine from 'components/StateMachine'; +import TaskDetails from 'components/TaskDetails'; + +import { isNully } from 'utils/Common'; +import { getClassForScheduleStatus, taskToStateMachine } from 'utils/Task'; + +export default function TaskStatus({ task }) { + if (isNully(task)) { + return (<Container> + <PanelGroup title={<StandardPanelTitle title='Active Task' />}> + <div>No active task found.</div> + </PanelGroup> + </Container>); + } + + return (<Container> + <PanelGroup title={<StandardPanelTitle title='Active Task' />}> + <div className='row'> + <div className='col-md-6'> + <TaskDetails task={task} /> + </div> + <div className='col-md-6'> + <StateMachine + className={getClassForScheduleStatus(task.status)} + states={taskToStateMachine(task)} /> + </div> + </div> + </PanelGroup> + </Container>); +} http://git-wip-us.apache.org/repos/asf/aurora/blob/8e8e8303/ui/src/main/js/components/__tests__/InstanceHistory-test.js ---------------------------------------------------------------------- diff --git a/ui/src/main/js/components/__tests__/InstanceHistory-test.js b/ui/src/main/js/components/__tests__/InstanceHistory-test.js new file mode 100644 index 0000000..1631481 --- /dev/null +++ b/ui/src/main/js/components/__tests__/InstanceHistory-test.js @@ -0,0 +1,24 @@ +import React from 'react'; +import { shallow } from 'enzyme'; + +import InstanceHistory from '../InstanceHistory'; +import InstanceHistoryItem from '../InstanceHistoryItem'; + +describe('InstanceHistory', () => { + it('Should reverse sort the tasks given to it by latest timestamp', () => { + const tasks = [ + {assignedTask: {taskId: 0}, taskEvents: [{timestamp: 2}]}, + {assignedTask: {taskId: 1}, taskEvents: [{timestamp: 1}, {timestamp: 10}]}, + {assignedTask: {taskId: 2}, taskEvents: [{timestamp: 3}]} + ]; + + const el = shallow(<InstanceHistory tasks={tasks} />); + const ids = el.find(InstanceHistoryItem).map((i) => i.props().task.assignedTask.taskId); + expect(ids).toEqual([1, 2, 0]); + }); + + it('Should handle empty lists', () => { + const el = shallow(<InstanceHistory tasks={[]} />); + expect(el.contains(<div>No task history found.</div>)).toBe(true); + }); +}); http://git-wip-us.apache.org/repos/asf/aurora/blob/8e8e8303/ui/src/main/js/components/__tests__/InstanceHistoryItem-test.js ---------------------------------------------------------------------- diff --git a/ui/src/main/js/components/__tests__/InstanceHistoryItem-test.js b/ui/src/main/js/components/__tests__/InstanceHistoryItem-test.js new file mode 100644 index 0000000..f86053a --- /dev/null +++ b/ui/src/main/js/components/__tests__/InstanceHistoryItem-test.js @@ -0,0 +1,63 @@ +import React from 'react'; +import { shallow } from 'enzyme'; + +import InstanceHistoryItem, { + InstanceHistoryBody, + InstanceHistoryHeader +} from '../InstanceHistoryItem'; + +import { ScheduledTaskBuilder, TaskEventBuilder } from 'test-utils/TaskBuilders'; + +describe('InstanceHistoryItem', () => { + it('Should be minimized by default', () => { + const task = ScheduledTaskBuilder.build(); + const el = shallow(<InstanceHistoryItem task={task} />); + expect(el.find(InstanceHistoryHeader).length).toBe(1); + expect(el.find(InstanceHistoryBody).length).toBe(0); + }); + + it('Should render body when expanded', () => { + const task = ScheduledTaskBuilder.build(); + const el = shallow(<InstanceHistoryItem expanded task={task} />); + expect(el.find(InstanceHistoryHeader).length).toBe(1); + expect(el.find(InstanceHistoryBody).length).toBe(1); + }); +}); + +describe('InstanceHistoryHeader', () => { + it('Should call toggle when the item details is clicked', () => { + const task = ScheduledTaskBuilder.build(); + const mockFn = jest.fn(); + const el = shallow(<InstanceHistoryHeader task={task} toggle={mockFn} />); + el.find('.instance-history-item-details').simulate('click'); + expect(mockFn.mock.calls.length).toBe(1); + }); + + it('Should render the attention status icon for pending tasks', () => { + const task = ScheduledTaskBuilder.status(ScheduleStatus.PENDING).build(); + const el = shallow(<InstanceHistoryHeader task={task} toggle={jest.fn()} />); + expect(el.find('.img-circle.attention').length).toBe(1); + }); + + it('Should render the okay status icon for finished tasks', () => { + const task = ScheduledTaskBuilder.status(ScheduleStatus.FINISHED).build(); + const el = shallow(<InstanceHistoryHeader task={task} toggle={jest.fn()} />); + expect(el.find('.img-circle.okay').length).toBe(1); + }); + + it('Should render the error status icon for failed tasks', () => { + const task = ScheduledTaskBuilder.status(ScheduleStatus.FAILED).build(); + const el = shallow(<InstanceHistoryHeader task={task} toggle={jest.fn()} />); + expect(el.find('.img-circle.error').length).toBe(1); + }); + + it('Should render the correct duration', () => { + const task = ScheduledTaskBuilder.taskEvents([ + TaskEventBuilder.timestamp(0).build(), + TaskEventBuilder.timestamp(10).build(), + TaskEventBuilder.timestamp(60000).build() + ]).build(); + const el = shallow(<InstanceHistoryHeader task={task} toggle={jest.fn()} />); + expect(el.contains(<span>Running duration: {'a minute'}</span>)).toBe(true); + }); +}); http://git-wip-us.apache.org/repos/asf/aurora/blob/8e8e8303/ui/src/main/js/components/__tests__/StateMachine-test.js ---------------------------------------------------------------------- diff --git a/ui/src/main/js/components/__tests__/StateMachine-test.js b/ui/src/main/js/components/__tests__/StateMachine-test.js new file mode 100644 index 0000000..3a6740a --- /dev/null +++ b/ui/src/main/js/components/__tests__/StateMachine-test.js @@ -0,0 +1,25 @@ +import React from 'react'; +import { shallow } from 'enzyme'; + +import StateMachine, { StateMachineToggle } from '../StateMachine'; + +describe('StateMachineToggle', () => { + it('Should toggle the display state when clicked', () => { + const states = [{ + state: 'One', + timestamp: 0 + }, { + state: 'Two', + timestamp: 0 + }]; + + const el = shallow(<StateMachineToggle states={states} toggleState={states[1]} />); + expect(el.state().expanded).toBe(false); + expect(el.contains(<StateMachine className={undefined} states={[states[1]]} />)).toBe(true); + + el.simulate('click'); + + expect(el.state().expanded).toBe(true); + expect(el.contains(<StateMachine className={undefined} states={states} />)).toBe(true); + }); +}); http://git-wip-us.apache.org/repos/asf/aurora/blob/8e8e8303/ui/src/main/js/index.js ---------------------------------------------------------------------- diff --git a/ui/src/main/js/index.js b/ui/src/main/js/index.js index 4a879e6..8f07734 100644 --- a/ui/src/main/js/index.js +++ b/ui/src/main/js/index.js @@ -5,6 +5,7 @@ import { BrowserRouter as Router, Route } from 'react-router-dom'; import SchedulerClient from 'client/scheduler-client'; import Navigation from 'components/Navigation'; import Home from 'pages/Home'; +import Instance from 'pages/Instance'; import Jobs from 'pages/Jobs'; import styles from '../sass/app.scss'; // eslint-disable-line no-unused-vars @@ -19,7 +20,10 @@ const SchedulerUI = () => ( <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={Home} exact path='/beta/scheduler/:role/:environment/:name/:instance' /> + <Route + 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' /> </div> http://git-wip-us.apache.org/repos/asf/aurora/blob/8e8e8303/ui/src/main/js/pages/Instance.js ---------------------------------------------------------------------- diff --git a/ui/src/main/js/pages/Instance.js b/ui/src/main/js/pages/Instance.js new file mode 100644 index 0000000..c4d625c --- /dev/null +++ b/ui/src/main/js/pages/Instance.js @@ -0,0 +1,53 @@ +import React from 'react'; + +import Breadcrumb from 'components/Breadcrumb'; +import InstanceHistory from 'components/InstanceHistory'; +import Loading from 'components/Loading'; +import TaskStatus from 'components/TaskStatus'; + +import { isActive } from 'utils/Task'; + +export default class Instance extends React.Component { + constructor(props) { + super(props); + this.state = {cluster: '', tasks: [], loading: true}; + } + + componentWillMount(props) { + const { role, environment, name, instance } = this.props.match.params; + const query = new TaskQuery(); + query.role = role; + query.environment = environment; + query.jobName = name; + query.instanceIds = [instance]; + + const that = this; + this.props.api.getTasksWithoutConfigs(query, (rsp) => { + that.setState({ + cluster: rsp.serverInfo.clusterName, + loading: false, + tasks: rsp.result.scheduleStatusResult.tasks + }); + }); + } + + render() { + const { role, environment, name, instance } = this.props.match.params; + if (this.state.loading) { + return <Loading />; + } + + const activeTask = this.state.tasks.find(isActive); + const terminalTasks = this.state.tasks.filter((t) => !isActive(t)); + return (<div className='instance-page'> + <Breadcrumb + cluster={this.state.cluster} + env={environment} + instance={instance} + name={name} + role={role} /> + <TaskStatus task={activeTask} /> + <InstanceHistory tasks={terminalTasks} /> + </div>); + } +} http://git-wip-us.apache.org/repos/asf/aurora/blob/8e8e8303/ui/src/main/js/pages/__tests__/Instance-test.js ---------------------------------------------------------------------- diff --git a/ui/src/main/js/pages/__tests__/Instance-test.js b/ui/src/main/js/pages/__tests__/Instance-test.js new file mode 100644 index 0000000..2395e2e --- /dev/null +++ b/ui/src/main/js/pages/__tests__/Instance-test.js @@ -0,0 +1,61 @@ +import React from 'react'; +import { shallow } from 'enzyme'; + +import Instance from '../Instance'; + +import Breadcrumb from 'components/Breadcrumb'; +import InstanceHistory from 'components/InstanceHistory'; +import Loading from 'components/Loading'; +import TaskStatus from 'components/TaskStatus'; + +const TEST_CLUSTER = 'test-cluster'; + +const params = { + role: 'test-role', + environment: 'test-env', + name: 'test-job', + instance: '1' +}; + +function createMockApi(tasks) { + const api = {}; + api.getTasksWithoutConfigs = (query, handler) => handler({ + result: { + scheduleStatusResult: { + tasks: tasks + } + }, + serverInfo: { + clusterName: TEST_CLUSTER + } + }); + return api; +} + +const tasks = [{ + status: ScheduleStatus.FAILED +}, { + status: ScheduleStatus.RUNNING +}, { + status: ScheduleStatus.KILLED +}]; + +describe('Instance', () => { + it('Should render Loading before data is fetched', () => { + expect(shallow(<Instance + api={{getTasksWithoutConfigs: () => {}}} + match={{params: params}} />).contains(<Loading />)).toBe(true); + }); + + it('Should render page elements when tasks are fetched', () => { + const el = shallow(<Instance api={createMockApi(tasks)} match={{params: params}} />); + expect(el.contains(<Breadcrumb + cluster={TEST_CLUSTER} + env={params.environment} + instance={params.instance} + name={params.name} + role={params.role} />)).toBe(true); + expect(el.contains(<TaskStatus task={tasks[1]} />)).toBe(true); + expect(el.contains(<InstanceHistory tasks={[tasks[0], tasks[2]]} />)).toBe(true); + }); +}); http://git-wip-us.apache.org/repos/asf/aurora/blob/8e8e8303/ui/src/main/js/test-utils/Builder.js ---------------------------------------------------------------------- diff --git a/ui/src/main/js/test-utils/Builder.js b/ui/src/main/js/test-utils/Builder.js new file mode 100644 index 0000000..f103a57 --- /dev/null +++ b/ui/src/main/js/test-utils/Builder.js @@ -0,0 +1,31 @@ +import { clone } from 'utils/Common'; + +/** + * Generates an immutable object builder from a base object. Each mutation + * clones the builder and also populates fields with values present in the + * original struct. + * + * Usage: + * + * const x = createBuilder({hello: 'world', test: true}); + * x.hello('universe').build(); // {hello: 'universe', test: true}; + */ +export default function createBuilder(base) { + function Builder() { + this._entity = clone(base); + } + + Object.keys(base).forEach((key) => { + Builder.prototype[key] = function (value) { + const updated = clone(this._entity); + updated[key] = value; + return createBuilder(updated); + }; + }); + + Builder.prototype.build = function () { + return clone(this._entity); + }; + + return new Builder(); +} http://git-wip-us.apache.org/repos/asf/aurora/blob/8e8e8303/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 new file mode 100644 index 0000000..35b9152 --- /dev/null +++ b/ui/src/main/js/test-utils/TaskBuilders.js @@ -0,0 +1,57 @@ +import createBuilder from 'test-utils/Builder'; + +const INSTANCE_ID = 0; +const TASK_ID = 'test-task-id'; +const SLAVE_ID = 'test-agent-id'; +const HOST = 'test-host'; +const TIER = 'preferred'; +const ROLE = 'test-role'; +const ENV = 'test-env'; +const NAME = 'test-name'; +const USER = 'user'; + +const JOB_KEY = { + role: ROLE, + environment: ENV, + name: NAME +}; + +export default { + HOST, INSTANCE_ID, SLAVE_ID, TASK_ID, ROLE, ENV, NAME, USER, JOB_KEY +}; + +export const TaskConfigBuilder = createBuilder({ + job: JOB_KEY, + owner: { + user: USER + }, + isService: true, + priority: 0, + maxTaskFailures: 0, + tier: TIER, + resources: [{numCpus: 1}, {ramMb: 1024}, {diskMb: 1024}], + constraints: [], + requestedPorts: [] +}); + +export const AssignedTaskBuilder = createBuilder({ + taskId: TASK_ID, + slaveId: SLAVE_ID, + slaveHost: HOST, + task: TaskConfigBuilder.build(), + assignedPorts: {}, + instanceId: INSTANCE_ID +}); + +export const TaskEventBuilder = createBuilder({ + timestamp: 0, + status: ScheduleStatus.PENDING +}); + +export const ScheduledTaskBuilder = createBuilder({ + assignedTask: AssignedTaskBuilder.build(), + status: ScheduleStatus.PENDING, + failureCount: 0, + taskEvents: [TaskEventBuilder.build()], + ancestorId: '' +}); http://git-wip-us.apache.org/repos/asf/aurora/blob/8e8e8303/ui/src/main/js/test-utils/__tests__/Builder-test.js ---------------------------------------------------------------------- diff --git a/ui/src/main/js/test-utils/__tests__/Builder-test.js b/ui/src/main/js/test-utils/__tests__/Builder-test.js new file mode 100644 index 0000000..120fe6f --- /dev/null +++ b/ui/src/main/js/test-utils/__tests__/Builder-test.js @@ -0,0 +1,32 @@ +import createBuilder from '../Builder'; + +describe('createBuilder', () => { + it('Should create a builder from a struct and allow me to chain mutators', () => { + const builder = createBuilder({test: true, test2: 5}); + + expect(builder.build()).toEqual({test: true, test2: 5}); + expect(builder.test(false).build()).toEqual({test: false, test2: 5}); + // original still intact + expect(builder.build()).toEqual({test: true, test2: 5}); + // chain updates + expect(builder.test(false).test2(10).build()).toEqual({test: false, test2: 10}); + }); + + it('Should keep default values stable even after object is changed', () => { + const test = {test: true, test2: 5}; + const builder = createBuilder(test); + + expect(builder.build()).toEqual({test: true, test2: 5}); + test.test = false; + expect(builder.build()).toEqual({test: true, test2: 5}); + }); + + it('Should not allow modifications to return values to modify the builder', () => { + const original = {test: true, test2: 5}; + const builder = createBuilder(original); + const result = builder.build(); + expect(result).toEqual(original); + result.test = false; + expect(builder.build()).toEqual(original); + }); +}); http://git-wip-us.apache.org/repos/asf/aurora/blob/8e8e8303/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 2e12e3c..be8766c 100644 --- a/ui/src/main/js/utils/Common.js +++ b/ui/src/main/js/utils/Common.js @@ -1,3 +1,22 @@ export function isNully(value) { return typeof value === 'undefined' || value === null; } + +export function invert(obj) { + const inverted = {}; + Object.keys(obj).forEach((key) => { + inverted[obj[key]] = key; + }); + return inverted; +} + +export function addClass(original, maybeClass) { + if (isNully(maybeClass) || maybeClass.length === 0) { + return original; + } + return `${original} ${maybeClass}`; +} + +export function clone(obj) { + return JSON.parse(JSON.stringify(obj)); +} http://git-wip-us.apache.org/repos/asf/aurora/blob/8e8e8303/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 new file mode 100644 index 0000000..c58ff7d --- /dev/null +++ b/ui/src/main/js/utils/Task.js @@ -0,0 +1,43 @@ +import moment from 'moment'; +import ThriftUtils, { SCHEDULE_STATUS } from 'utils/Thrift'; + +export function isActive(task) { + return ACTIVE_STATES.includes(task.status); +} + +export function getClassForScheduleStatus(status) { + if (ThriftUtils.OKAY_SCHEDULE_STATUS.includes(status)) { + return 'okay'; + } else if (ThriftUtils.WARNING_SCHEDULE_STATUS.includes(status)) { + return 'attention'; + } else if (ThriftUtils.ERROR_SCHEDULE_STATUS.includes(status)) { + return 'error'; + } else if (ThriftUtils.USER_WAIT_SCHEDULE_STATUS.includes(status)) { + return 'in-progress'; + } + return 'system'; +} + +export function taskToStateMachine(task) { + return task.taskEvents.map((e, i) => { + const active = (i === task.taskEvents.length - 1) ? ' active' : ''; + return { + timestamp: e.timestamp, + className: `${getClassForScheduleStatus(e.status)}${active}`, + state: SCHEDULE_STATUS[e.status], + message: e.message + }; + }); +} + +export function getLastEventTime(task) { + if (task.taskEvents.length > 0) { + return task.taskEvents[task.taskEvents.length - 1].timestamp; + } +} + +export function getDuration(task) { + const firstEvent = moment(task.taskEvents[0].timestamp); + const latestEvent = moment(task.taskEvents[task.taskEvents.length - 1].timestamp); + return moment.duration(latestEvent.diff(firstEvent)).humanize(); +} http://git-wip-us.apache.org/repos/asf/aurora/blob/8e8e8303/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 new file mode 100644 index 0000000..b247e36 --- /dev/null +++ b/ui/src/main/js/utils/Thrift.js @@ -0,0 +1,33 @@ +import { invert } from 'utils/Common'; + +export const SCHEDULE_STATUS = invert(ScheduleStatus); + +export const OKAY_SCHEDULE_STATUS = [ + ScheduleStatus.RUNNING, + ScheduleStatus.FINISHED +]; + +export const WARNING_SCHEDULE_STATUS = [ + ScheduleStatus.ASSIGNED, + ScheduleStatus.PENDING, + ScheduleStatus.LOST, + ScheduleStatus.KILLING, + ScheduleStatus.DRAINING, + ScheduleStatus.PREEMPTING +]; + +export const USER_WAIT_SCHEDULE_STATUS = [ + ScheduleStatus.STARTING +]; + +export const ERROR_SCHEDULE_STATUS = [ + ScheduleStatus.THROTTLED, + ScheduleStatus.FAILED +]; + +export default { + OKAY_SCHEDULE_STATUS, + WARNING_SCHEDULE_STATUS, + ERROR_SCHEDULE_STATUS, + USER_WAIT_SCHEDULE_STATUS +}; http://git-wip-us.apache.org/repos/asf/aurora/blob/8e8e8303/ui/src/main/sass/app.scss ---------------------------------------------------------------------- diff --git a/ui/src/main/sass/app.scss b/ui/src/main/sass/app.scss index d9673d0..e301d4c 100644 --- a/ui/src/main/sass/app.scss +++ b/ui/src/main/sass/app.scss @@ -7,8 +7,11 @@ /* Indiviudal Components */ @import 'components/breadcrumb'; @import 'components/navigation'; +@import 'components/state-machine'; +@import 'components/status'; @import 'components/tables'; /* Page Styles */ @import 'components/home-page'; +@import 'components/instance-page'; @import 'components/job-list-page'; \ No newline at end of file http://git-wip-us.apache.org/repos/asf/aurora/blob/8e8e8303/ui/src/main/sass/components/_instance-page.scss ---------------------------------------------------------------------- diff --git a/ui/src/main/sass/components/_instance-page.scss b/ui/src/main/sass/components/_instance-page.scss new file mode 100644 index 0000000..99204fd --- /dev/null +++ b/ui/src/main/sass/components/_instance-page.scss @@ -0,0 +1,111 @@ +.instance-page { + .active-task-details { + h5, .task-details-title { + text-transform: uppercase; + margin: 0; + margin-bottom: 3px; + font-weight: 700; + } + + code { + background-color: $content_box_color; + color: #555; + padding: 0; + } + + a { + display:block; + line-height: 1em; + margin-bottom: 20px; + } + } + + .instance-history { + .state-machine, .state-machine ul, .state-machine li { + margin: 0 !important; + } + + .instance-history-item-body { + padding: 20px; + } + + .instance-history-item-footer { + padding: 10px 40px; + font-size: 12px; + + strong { + text-transform: uppercase; + } + + span { + display: inline-block; + background-color: rgba(0, 0, 0, 0.02); + padding: 3px 10px; + } + } + + .instance-history-item { + display: flex; + align-items: center; + padding: 10px; + } + + .instance-history-time { + margin: 0px 5px; + font-size: 12px; + } + + .instance-history-message { + color: $secondary_font_color; + font-size: 12px; + } + + .img-circle { + margin: 0px 10px; + width: 8px; + height: 8px; + } + + .instance-history-item-details { + margin: 0px 5px; + + h5 { + display: inline-block; + margin: 0; + font-weight: 600; + } + + &:hover { + cursor: pointer; + cursor: hand; + } + } + + .instance-history-item-actions { + margin: 0; + margin-left: auto; + list-style-type: none; + padding: 0; + + li { + display: inline-block; + padding: 0; + margin: 0; + } + + a { + display: inline-block; + border: 1px solid $grid_color; + background-color: $content_box_color; + padding: 5px 10px; + margin-left: 3px; + font-weight: 600; + } + } + } + + .debug-data { + font-family: Menlo,Monaco,Consolas,"Courier New",monospace; + font-size: 90%; + } +} \ No newline at end of file http://git-wip-us.apache.org/repos/asf/aurora/blob/8e8e8303/ui/src/main/sass/components/_job-list-page.scss ---------------------------------------------------------------------- diff --git a/ui/src/main/sass/components/_job-list-page.scss b/ui/src/main/sass/components/_job-list-page.scss index d31344d..016bff1 100644 --- a/ui/src/main/sass/components/_job-list-page.scss +++ b/ui/src/main/sass/components/_job-list-page.scss @@ -9,14 +9,6 @@ margin-right: 12px; font-size: 16px; } - - .img-circle { - width: 10px; - height: 10px; - background-color: #CCC; - display: inline-block; - border-radius: 50%; - } } .job-list-sort-control { http://git-wip-us.apache.org/repos/asf/aurora/blob/8e8e8303/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 1d0553b..4ebec05 100644 --- a/ui/src/main/sass/components/_layout.scss +++ b/ui/src/main/sass/components/_layout.scss @@ -29,4 +29,37 @@ .content-panel + .content-panel { margin-top: 1px; } +} + +.content-panel-fluid { + .content-panel { + padding: 0px !important; + } +} + +.tip { + position: relative; +} + +.tip::before { + content: attr(data-tip) ; + font-size: 10px; + position:absolute; + z-index: 999; + white-space:nowrap; + bottom:9999px; + left: 50%; + background:#000; + color:#e0e0e0; + padding:0px 7px; + line-height: 24px; + height: 24px; + + opacity: 0; + transition:opacity 0.4s ease-out; + } + +.tip:hover::before { + opacity: 1; + bottom:-35px; } \ No newline at end of file http://git-wip-us.apache.org/repos/asf/aurora/blob/8e8e8303/ui/src/main/sass/components/_state-machine.scss ---------------------------------------------------------------------- diff --git a/ui/src/main/sass/components/_state-machine.scss b/ui/src/main/sass/components/_state-machine.scss new file mode 100644 index 0000000..804746d --- /dev/null +++ b/ui/src/main/sass/components/_state-machine.scss @@ -0,0 +1,114 @@ +.state-machine { + position: relative; + margin-top: 20px; + padding-left: 120px; + + ul { + list-style-type: none; + padding: 0; + } + + ul:before { + position: absolute; + top: 10px; + bottom: 25px; + display: block; + width: 3px; + content: ""; + background-color: $grid_color; + } + + ul.okay:before { + background: linear-gradient($grid_highlight_color, $grid_highlight_color, $colors_success); + } + + ul.error:before { + background: linear-gradient($grid_highlight_color, $grid_highlight_color, $colors_error); + } + + ul.attention:before { + background: linear-gradient($grid_highlight_color, $grid_highlight_color, $colors_warning); + } + + ul.in-progress:before { + background: linear-gradient($grid_highlight_color, $grid_highlight_color, $colors_highlight); + } + + li { + margin: 20px 0px; + position: relative; + color: $secondary_font_color; + min-height: 30px; + } + + li.active { + color: $primary_font_color; + } + + .state-machine-item { + display: flex; + flex-direction: row; + } + + svg { + width: 15px; + height: 15px; + margin-left: -4px; + margin-right: 5px; + display: inline-block; + margin-top: 4px; + } + + .state-machine-bullet { + fill: $grid_color; + stroke: $grid_highlight_color; + stroke-width: 2; + } + + .active.okay .state-machine-bullet { + fill: $colors_success_light; + stroke: $colors_success; + } + + .active.attention .state-machine-bullet { + fill: #f3bc88; + stroke: #FA9F47; + } + + .active.error .state-machine-bullet { + fill: $colors_error_light; + stroke: $colors_error; + } + + .active.in-progress .state-machine-bullet { + fill: $colors_highlight_light; + stroke: $colors_highlight; + } + + .state-machine-item-details { + display: inline-block; + position: relative; + width: 100%; + } + + .state-machine-item-state { + font-size: 14px; + font-weight: 500; + width: 100px; + display: inline-block; + } + + .state-machine-item-time { + font-size: 11px; + color: $secondary_font_color; + position: absolute; + left: -120px; + text-align: right; + } + + .state-machine-item-message { + display: block; + font-size: 12px; + color: $secondary_font_color; + } +} \ No newline at end of file http://git-wip-us.apache.org/repos/asf/aurora/blob/8e8e8303/ui/src/main/sass/components/_status.scss ---------------------------------------------------------------------- diff --git a/ui/src/main/sass/components/_status.scss b/ui/src/main/sass/components/_status.scss new file mode 100644 index 0000000..e1ac62f --- /dev/null +++ b/ui/src/main/sass/components/_status.scss @@ -0,0 +1,23 @@ +.img-circle { + width: 10px; + height: 10px; + background-color: #CCC; + display: inline-block; + border-radius: 50%; + + &.okay { + background-color: $colors_success; + } + + &.error { + background-color: $colors_error; + } + + &.attention { + background-color: $colors_warning; + } + + &.in-progress { + background-color: $colors_highlight; + } +} http://git-wip-us.apache.org/repos/asf/aurora/blob/8e8e8303/ui/test-setup.js ---------------------------------------------------------------------- diff --git a/ui/test-setup.js b/ui/test-setup.js index 054e7c2..a403434 100644 --- a/ui/test-setup.js +++ b/ui/test-setup.js @@ -3,3 +3,29 @@ import { configure } from 'enzyme'; import Adapter from 'enzyme-adapter-react-16'; configure({ adapter: new Adapter() }); + +// Forced to do this because of how Thrift is wired into the UI. Namely that +// the jQuery/web-based Thrift generated code writes things into global namespace - but omits +// the var from the variable assignment (otherwise we could just eval the file here to load +// it into the global namespace). Unfortunately it means our Thrift unit tests +// can fall out of sync with API changes - but I don't see a way around this that isn't brittle +// to changes to the API anyway. +global.ScheduleStatus = { + 'INIT' : 11, + 'THROTTLED' : 16, + 'PENDING' : 0, + 'ASSIGNED' : 9, + 'STARTING' : 1, + 'RUNNING' : 2, + 'FINISHED' : 3, + 'PREEMPTING' : 13, + 'RESTARTING' : 12, + 'DRAINING' : 17, + 'FAILED' : 4, + 'KILLED' : 5, + 'KILLING' : 6, + 'LOST' : 7 +}; +global.ACTIVE_STATES = [9,17,6,0,13,12,2,1,16]; + +global.TaskQuery = () => {}; \ No newline at end of file
