You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@aurora.apache.org by dm...@apache.org on 2017/10/12 21:31:25 UTC

aurora git commit: Implement Job page in React

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 <da...@dmclaughlin.com>
Authored: Thu Oct 12 14:08:39 2017 -0700
Committer: David McLaughlin <da...@dmclaughlin.com>
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> &bull; <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