aurora-commits mailing list archives

Site index · List index
Message view « Date » · « Thread »
Top « Date » · « Thread »
From dmclaugh...@apache.org
Subject aurora git commit: Implement Instance pages in React
Date Tue, 10 Oct 2017 16:01:14 GMT
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 <david@dmclaughlin.com>
Authored: Tue Oct 10 08:53:32 2017 -0700
Committer: David McLaughlin <david@dmclaughlin.com>
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> &bull; </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


Mime
View raw message