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/10 17:15:00 UTC

aurora git commit: Implement Update and Updates pages in React.

Repository: aurora
Updated Branches:
  refs/heads/master 2df250e3e -> 4a1fba3c8


Implement Update and Updates pages in React.

Reviewed at https://reviews.apache.org/r/62763/


Project: http://git-wip-us.apache.org/repos/asf/aurora/repo
Commit: http://git-wip-us.apache.org/repos/asf/aurora/commit/4a1fba3c
Tree: http://git-wip-us.apache.org/repos/asf/aurora/tree/4a1fba3c
Diff: http://git-wip-us.apache.org/repos/asf/aurora/diff/4a1fba3c

Branch: refs/heads/master
Commit: 4a1fba3c8ae1dd9a302590f3fdfcc8852636c0bf
Parents: 2df250e
Author: David McLaughlin <da...@dmclaughlin.com>
Authored: Tue Oct 10 10:14:50 2017 -0700
Committer: David McLaughlin <da...@dmclaughlin.com>
Committed: Tue Oct 10 10:14:50 2017 -0700

----------------------------------------------------------------------
 ui/.eslintrc                                    |  10 +-
 ui/package.json                                 |   6 +-
 ui/src/main/js/components/InstanceViz.js        |  17 ++
 ui/src/main/js/components/Layout.js             |  18 +-
 ui/src/main/js/components/Pagination.js         |  10 +-
 ui/src/main/js/components/TaskConfig.js         |   5 +
 ui/src/main/js/components/Time.js               |   6 +
 ui/src/main/js/components/UpdateConfig.js       |  12 +
 ui/src/main/js/components/UpdateDetails.js      |  28 ++
 .../main/js/components/UpdateInstanceEvents.js  | 101 +++++++
 .../main/js/components/UpdateInstanceSummary.js |  22 ++
 ui/src/main/js/components/UpdateList.js         |  47 +++
 ui/src/main/js/components/UpdateSettings.js     |  30 ++
 ui/src/main/js/components/UpdateStateMachine.js |  21 ++
 ui/src/main/js/components/UpdateStatus.js       |  22 ++
 ui/src/main/js/components/UpdateTime.js         |  33 +++
 ui/src/main/js/components/UpdateTitle.js        |  26 ++
 .../js/components/__tests__/InstanceViz-test.js |  41 +++
 .../js/components/__tests__/Pagination-test.js  |  25 ++
 .../__tests__/UpdateInstanceEvents-test.js      |  40 +++
 .../js/components/__tests__/UpdateList-test.js  |  17 ++
 .../components/__tests__/UpdateStatus-test.js   |  23 ++
 ui/src/main/js/index.js                         |   9 +-
 ui/src/main/js/pages/Update.js                  |  54 ++++
 ui/src/main/js/pages/Updates.js                 |  66 +++++
 ui/src/main/js/pages/__tests__/Update-test.js   |  56 ++++
 ui/src/main/js/pages/__tests__/Updates-test.js  |  33 +++
 ui/src/main/js/test-utils/UpdateBuilders.js     |  86 ++++++
 ui/src/main/js/utils/Common.js                  |  16 +
 ui/src/main/js/utils/Thrift.js                  |  43 ++-
 ui/src/main/js/utils/Update.js                  | 163 +++++++++++
 ui/src/main/js/utils/__tests__/Update-test.js   | 291 +++++++++++++++++++
 ui/src/main/sass/app.scss                       |   5 +-
 ui/src/main/sass/components/_instance-viz.scss  |  78 +++++
 ui/src/main/sass/components/_layout.scss        |  85 ++++++
 ui/src/main/sass/components/_update-list.scss   |  38 +++
 ui/src/main/sass/components/_update-page.scss   | 151 ++++++++++
 ui/test-setup.js                                |  37 ++-
 38 files changed, 1759 insertions(+), 12 deletions(-)
----------------------------------------------------------------------


http://git-wip-us.apache.org/repos/asf/aurora/blob/4a1fba3c/ui/.eslintrc
----------------------------------------------------------------------
diff --git a/ui/.eslintrc b/ui/.eslintrc
index 84a6d37..f7ac075 100644
--- a/ui/.eslintrc
+++ b/ui/.eslintrc
@@ -10,10 +10,16 @@
   },
   "globals": {
     "ACTIVE_STATES": true,
-    "Thrift": true,
+    "ACTIVE_JOB_UPDATE_STATES": true,
+    "JobKey": true,
+    "JobUpdateAction": true,
+    "JobUpdateKey": true,
+    "JobUpdateQuery": true,
+    "JobUpdateStatus": true,
     "ReadOnlySchedulerClient": true,
     "ScheduleStatus": true,
-    "TaskQuery": true
+    "TaskQuery": true,
+    "Thrift": true
   },
   "plugins": [
     "chai-friendly"

http://git-wip-us.apache.org/repos/asf/aurora/blob/4a1fba3c/ui/package.json
----------------------------------------------------------------------
diff --git a/ui/package.json b/ui/package.json
index 6e8ad7a..f4532df 100644
--- a/ui/package.json
+++ b/ui/package.json
@@ -4,6 +4,7 @@
   "description": "UI project for Apache Aurora",
   "main": "index.js",
   "dependencies": {
+    "es6-shim": "^0.35.3",
     "moment": "^2.18.1",
     "react": "^16.0.0",
     "react-dom": "^16.0.0",
@@ -40,7 +41,10 @@
     "webpack": "^2.6.1"
   },
   "jest": {
-    "moduleDirectories": ["./src/main/js", "node_modules"],
+    "moduleDirectories": [
+      "./src/main/js",
+      "node_modules"
+    ],
     "setupFiles": [
       "./test-setup.js"
     ]

http://git-wip-us.apache.org/repos/asf/aurora/blob/4a1fba3c/ui/src/main/js/components/InstanceViz.js
----------------------------------------------------------------------
diff --git a/ui/src/main/js/components/InstanceViz.js b/ui/src/main/js/components/InstanceViz.js
new file mode 100644
index 0000000..99efec4
--- /dev/null
+++ b/ui/src/main/js/components/InstanceViz.js
@@ -0,0 +1,17 @@
+import React from 'react';
+import { Link } from 'react-router-dom';
+
+export default function InstanceViz({ instances, jobKey }) {
+  const {job: {role, environment, name}} = jobKey;
+  const className = (instances.length > 1000)
+    ? 'small'
+    : (instances.length > 100) ? 'medium' : 'big';
+
+  return (<ul className={`instance-grid ${className}`}>
+    {instances.map((i) => {
+      return (<Link key={i} to={`/beta/scheduler/${role}/${environment}/${name}/${i.instanceId}`}>
+        <li className={i.className} title={i.title} />
+      </Link>);
+    })}
+  </ul>);
+}

http://git-wip-us.apache.org/repos/asf/aurora/blob/4a1fba3c/ui/src/main/js/components/Layout.js
----------------------------------------------------------------------
diff --git a/ui/src/main/js/components/Layout.js b/ui/src/main/js/components/Layout.js
index 50d63e6..b63b0c7 100644
--- a/ui/src/main/js/components/Layout.js
+++ b/ui/src/main/js/components/Layout.js
@@ -1,8 +1,10 @@
 import React from 'react';
 
+import Icon from 'components/Icon';
+
 import { addClass } from 'utils/Common';
 
-function ContentPanel({ children }) {
+export function ContentPanel({ children }) {
   return <div className='content-panel'>{children}</div>;
 }
 
@@ -10,6 +12,18 @@ export function StandardPanelTitle({ title }) {
   return <div className='content-panel-title'>{title}</div>;
 }
 
+export function PanelSubtitle({ title }) {
+  return <div className='content-panel-subtitle'>{title}</div>;
+}
+
+export function IconPanelTitle({ title, className, icon }) {
+  return (<div className={`content-icon-title ${className}`}>
+    <div className='content-icon-title-text'>
+      <Icon name={icon} /> {title}
+    </div>
+  </div>);
+}
+
 export default function PanelGroup({ children, title, noPadding }) {
   const extraClass = noPadding ? ' content-panel-fluid' : '';
   return (<div className={addClass('content-panel-group', extraClass)}>
@@ -25,7 +39,7 @@ export function PanelRow({ children }) {
 }
 
 export function Container({ children, className }) {
-  const width = 12 / children.length;
+  const width = 12 / (children.length || 1);
   return (<div className={addClass('container', className)}>
     <div className='row'>
       {React.Children.map(children, (c) => <div className={`col-md-${width}`}>{c}</div>)}

http://git-wip-us.apache.org/repos/asf/aurora/blob/4a1fba3c/ui/src/main/js/components/Pagination.js
----------------------------------------------------------------------
diff --git a/ui/src/main/js/components/Pagination.js b/ui/src/main/js/components/Pagination.js
index 7bf2c04..0aac09d 100644
--- a/ui/src/main/js/components/Pagination.js
+++ b/ui/src/main/js/components/Pagination.js
@@ -54,6 +54,10 @@ export default class Pagination extends React.Component {
 
   sort(data) {
     const { reverseSort, sortBy } = this.props;
+    if (!sortBy) {
+      return data;
+    }
+
     const gte = reverseSort ? -1 : 1;
     const lte = reverseSort ? 1 : -1;
     if (typeof sortBy === 'function') {
@@ -68,7 +72,7 @@ export default class Pagination extends React.Component {
 
   render() {
     const that = this;
-    const { data, isTable, maxPages, numberPerPage, renderer } = this.props;
+    const { data, isTable, maxPages, numberPerPage, renderer, hideIfSinglePage } = this.props;
     const { page } = this.state;
 
     // Apply the filter before we try to paginate.
@@ -86,8 +90,10 @@ export default class Pagination extends React.Component {
     // but first attempts at this broke shallow rendering in enzyme.
     const elements = currentPageItems.map(renderer);
 
+    const numPages = Math.ceil(filtered.length / numberPerPage);
+
     // The clickable page list.
-    const pagination = <PageNavigation
+    const pagination = (numPages === 1 && hideIfSinglePage) ? '' : <PageNavigation
       currentPage={page}
       maxPages={maxPages || 8}
       numPages={Math.ceil(filtered.length / numberPerPage)}

http://git-wip-us.apache.org/repos/asf/aurora/blob/4a1fba3c/ui/src/main/js/components/TaskConfig.js
----------------------------------------------------------------------
diff --git a/ui/src/main/js/components/TaskConfig.js b/ui/src/main/js/components/TaskConfig.js
new file mode 100644
index 0000000..b8531cb
--- /dev/null
+++ b/ui/src/main/js/components/TaskConfig.js
@@ -0,0 +1,5 @@
+import React from 'react';
+
+export default function TaskConfig({ config }) {
+  return <pre>{JSON.stringify(config, null, 2)}</pre>;
+}

http://git-wip-us.apache.org/repos/asf/aurora/blob/4a1fba3c/ui/src/main/js/components/Time.js
----------------------------------------------------------------------
diff --git a/ui/src/main/js/components/Time.js b/ui/src/main/js/components/Time.js
new file mode 100644
index 0000000..0e8d984
--- /dev/null
+++ b/ui/src/main/js/components/Time.js
@@ -0,0 +1,6 @@
+import moment from 'moment';
+import React from 'react';
+
+export function RelativeTime({ ts }) {
+  return <span>{moment(ts).fromNow()}</span>;
+}

http://git-wip-us.apache.org/repos/asf/aurora/blob/4a1fba3c/ui/src/main/js/components/UpdateConfig.js
----------------------------------------------------------------------
diff --git a/ui/src/main/js/components/UpdateConfig.js b/ui/src/main/js/components/UpdateConfig.js
new file mode 100644
index 0000000..fac3c88
--- /dev/null
+++ b/ui/src/main/js/components/UpdateConfig.js
@@ -0,0 +1,12 @@
+import React from 'react';
+
+import PanelGroup, { Container, StandardPanelTitle } from 'components/Layout';
+import TaskConfig from 'components/TaskConfig';
+
+export default function UpdateConfig({ update }) {
+  return (<Container>
+    <PanelGroup noPadding title={<StandardPanelTitle title='Update Config' />}>
+      <TaskConfig config={update.update.instructions.desiredState.task} />
+    </PanelGroup>
+  </Container>);
+}

http://git-wip-us.apache.org/repos/asf/aurora/blob/4a1fba3c/ui/src/main/js/components/UpdateDetails.js
----------------------------------------------------------------------
diff --git a/ui/src/main/js/components/UpdateDetails.js b/ui/src/main/js/components/UpdateDetails.js
new file mode 100644
index 0000000..b9dd565
--- /dev/null
+++ b/ui/src/main/js/components/UpdateDetails.js
@@ -0,0 +1,28 @@
+import React from 'react';
+
+import { Container, ContentPanel, StandardPanelTitle, PanelSubtitle } from 'components/Layout';
+import UpdateInstanceEvents from 'components/UpdateInstanceEvents';
+import UpdateInstanceSummary from 'components/UpdateInstanceSummary';
+import UpdateSettings from 'components/UpdateSettings';
+import UpdateStateMachine from 'components/UpdateStateMachine';
+import UpdateStatus from 'components/UpdateStatus';
+import UpdateTitle from 'components/UpdateTitle';
+
+export default function UpdateDetails({ update }) {
+  return (<Container>
+    <div className='content-panel-group'>
+      <UpdateTitle update={update} />
+      <ContentPanel><UpdateStatus update={update} /></ContentPanel>
+      <PanelSubtitle title='Update History' />
+      <ContentPanel><UpdateStateMachine update={update} /></ContentPanel>
+      <PanelSubtitle title='Update Settings' />
+      <ContentPanel><UpdateSettings update={update} /></ContentPanel>
+    </div>
+    <div className='content-panel-group'>
+      <StandardPanelTitle title='Instance Overview' />
+      <ContentPanel><UpdateInstanceSummary update={update} /></ContentPanel>
+      <PanelSubtitle title='Instance Events' />
+      <div className='content-panel fluid'><UpdateInstanceEvents update={update} /></div>
+    </div>
+  </Container>);
+}

http://git-wip-us.apache.org/repos/asf/aurora/blob/4a1fba3c/ui/src/main/js/components/UpdateInstanceEvents.js
----------------------------------------------------------------------
diff --git a/ui/src/main/js/components/UpdateInstanceEvents.js b/ui/src/main/js/components/UpdateInstanceEvents.js
new file mode 100644
index 0000000..f0dfae2
--- /dev/null
+++ b/ui/src/main/js/components/UpdateInstanceEvents.js
@@ -0,0 +1,101 @@
+import moment from 'moment';
+import React from 'react';
+import { Link } from 'react-router-dom';
+
+import Icon from 'components/Icon';
+import Pagination from 'components/Pagination';
+import StateMachine from 'components/StateMachine';
+
+import { addClass, sort } from 'utils/Common';
+import { UPDATE_ACTION } from 'utils/Thrift';
+import { actionDispatcher, getClassForUpdateAction } from 'utils/Update';
+
+const instanceEventIcon = actionDispatcher({
+  success: (e) => <Icon name='ok' />,
+  warning: (e) => <Icon name='warning-sign' />,
+  error: (e) => <Icon name='remove' />,
+  inProgress: (e) => <Icon name='play-circle' />
+});
+
+export class InstanceEvent extends React.Component {
+  constructor(props) {
+    super(props);
+    this.state = { expanded: props.expanded || false };
+  }
+
+  _stateMachine(events) {
+    const states = events.map((e, i) => {
+      return {
+        className: addClass(
+          getClassForUpdateAction(e.action),
+          (i === events.length - 1) ? ' active' : ''),
+        state: UPDATE_ACTION[e.action],
+        timestamp: e.timestampMs
+      };
+    });
+
+    return (<div className='update-instance-history'>
+      <StateMachine
+        className={getClassForUpdateAction(events[events.length - 1].action)}
+        states={states} />
+    </div>);
+  }
+
+  expand() {
+    this.setState({ expanded: !this.state.expanded });
+  }
+
+  render() {
+    const {events, instanceId, jobKey: {job: {role, environment, name}}} = this.props;
+    const sorted = sort(events, (e) => e.timestampMs);
+    const stateMachine = this.state.expanded ? this._stateMachine(sorted) : '';
+    const icon = this.state.expanded ? <Icon name='chevron-down' /> : <Icon name='chevron-right' />;
+    const latestEvent = sorted[sorted.length - 1];
+    return (<div className='update-instance-event-container'>
+      <div className='update-instance-event' onClick={(e) => this.expand()}>
+        {icon}
+        <span className='update-instance-event-id'>
+          <Link to={`/beta/scheduler/${role}/${environment}/${name}/${instanceId}`}>
+            #{instanceId}
+          </Link>
+        </span>
+        <span className='update-instance-event-status'>
+          {UPDATE_ACTION[latestEvent.action]}
+          <span className={getClassForUpdateAction(latestEvent.action)}>
+            {instanceEventIcon(latestEvent)}
+          </span>
+        </span>
+        <span className='update-instance-event-time'>
+          {moment(latestEvent.timestampMs).utc().format('HH:mm:ss') + ' UTC'}
+        </span>
+      </div>
+      {stateMachine}
+    </div>);
+  }
+};
+
+export default function UpdateInstanceEvents({ update }) {
+  const sortedEvents = sort(update.instanceEvents, (e) => e.timestampMs, true);
+  const instanceMap = {};
+  const eventOrder = [];
+  sortedEvents.forEach((e) => {
+    const existing = instanceMap[e.instanceId];
+    if (existing) {
+      instanceMap[e.instanceId].push(e);
+    } else {
+      eventOrder.push(e.instanceId);
+      instanceMap[e.instanceId] = [e];
+    }
+  });
+
+  return (<div className='instance-events'>
+    <Pagination
+      data={eventOrder}
+      hideIfSinglePage
+      numberPerPage={10}
+      renderer={(instanceId) => <InstanceEvent
+        events={instanceMap[instanceId]}
+        instanceId={instanceId}
+        jobKey={update.update.summary.key} />} />
+  </div>);
+}

http://git-wip-us.apache.org/repos/asf/aurora/blob/4a1fba3c/ui/src/main/js/components/UpdateInstanceSummary.js
----------------------------------------------------------------------
diff --git a/ui/src/main/js/components/UpdateInstanceSummary.js b/ui/src/main/js/components/UpdateInstanceSummary.js
new file mode 100644
index 0000000..3cbc28e
--- /dev/null
+++ b/ui/src/main/js/components/UpdateInstanceSummary.js
@@ -0,0 +1,22 @@
+import React from 'react';
+
+import InstanceViz from 'components/InstanceViz';
+
+import { instanceSummary, updateStats } from 'utils/Update';
+
+function UpdateStats({ update }) {
+  const stats = updateStats(update);
+  return (<div className='update-summary-stats'>
+    <h5>Instance Summary</h5>
+    <span className='stats'>
+      {stats.instancesUpdated} / {stats.totalInstancesToBeUpdated} ({stats.progress}%)
+    </span>
+  </div>);
+};
+
+export default function UpdateInstanceSummary({ update }) {
+  return (<div>
+    <UpdateStats update={update} />
+    <InstanceViz instances={instanceSummary(update)} jobKey={update.update.summary.key} />
+  </div>);
+}

http://git-wip-us.apache.org/repos/asf/aurora/blob/4a1fba3c/ui/src/main/js/components/UpdateList.js
----------------------------------------------------------------------
diff --git a/ui/src/main/js/components/UpdateList.js b/ui/src/main/js/components/UpdateList.js
new file mode 100644
index 0000000..3f57669
--- /dev/null
+++ b/ui/src/main/js/components/UpdateList.js
@@ -0,0 +1,47 @@
+import React from 'react';
+import { Link } from 'react-router-dom';
+
+import Loading from 'components/Loading';
+import Pagination from 'components/Pagination';
+import { RelativeTime } from 'components/Time';
+
+import { isNully } from 'utils/Common';
+import { UPDATE_STATUS } from 'utils/Thrift';
+import { getClassForUpdateStatus } from 'utils/Update';
+
+function UpdateListItem({ summary }) {
+  const {job: {role, environment, name}, id} = summary.key;
+  return (<div className='update-list-item'>
+    <span className={`img-circle ${getClassForUpdateStatus(summary.state.status)}`} />
+    <div className='update-list-item-details'>
+      <span className='update-list-item-status'>
+        <Link
+          className='update-list-job'
+          to={`/beta/scheduler/${role}/${environment}/${name}/update/${id}`}>
+          {role}/{environment}/{name}
+        </Link> &bull; <span className='update-list-status'>
+          {UPDATE_STATUS[summary.state.status]}
+        </span>
+      </span>
+      started by <span className='update-list-user'>
+        {summary.user} </span> <RelativeTime ts={summary.state.createdTimestampMs} />
+    </div>
+    <span className='update-list-last-updated'>
+      updated <RelativeTime ts={summary.state.lastModifiedTimestampMs} />
+    </span>
+  </div>);
+}
+
+export default function UpdateList({ updates }) {
+  if (isNully(updates)) {
+    return <Loading />;
+  }
+
+  return (<div className='update-list'>
+    <Pagination
+      data={updates}
+      hideIfSinglePage
+      numberPerPage={25}
+      renderer={(u) => <UpdateListItem key={u.key.id} summary={u} />} />
+  </div>);
+}

http://git-wip-us.apache.org/repos/asf/aurora/blob/4a1fba3c/ui/src/main/js/components/UpdateSettings.js
----------------------------------------------------------------------
diff --git a/ui/src/main/js/components/UpdateSettings.js b/ui/src/main/js/components/UpdateSettings.js
new file mode 100644
index 0000000..d756f59
--- /dev/null
+++ b/ui/src/main/js/components/UpdateSettings.js
@@ -0,0 +1,30 @@
+import moment from 'moment';
+import React from 'react';
+
+export default function UpdateSettings({ update }) {
+  const settings = update.update.instructions.settings;
+  return (<div>
+    <table className='update-settings'>
+      <tr>
+        <td>Batch Size</td>
+        <td>{settings.updateGroupSize}</td>
+      </tr>
+      <tr>
+        <td>Max Failures Per Instance</td>
+        <td>{settings.maxPerInstanceFailures}</td>
+      </tr>
+      <tr>
+        <td>Max Failed Instances</td>
+        <td>{settings.maxFailedInstances}</td>
+      </tr>
+      <tr>
+        <td>Minimum Waiting Time in Running</td>
+        <td>{moment.duration(settings.minWaitInInstanceRunningMs).humanize()}</td>
+      </tr>
+      <tr>
+        <td>Rollback On Failure?</td>
+        <td>{settings.rollbackOnFailure ? 'yes' : 'no'}</td>
+      </tr>
+    </table>
+  </div>);
+}

http://git-wip-us.apache.org/repos/asf/aurora/blob/4a1fba3c/ui/src/main/js/components/UpdateStateMachine.js
----------------------------------------------------------------------
diff --git a/ui/src/main/js/components/UpdateStateMachine.js b/ui/src/main/js/components/UpdateStateMachine.js
new file mode 100644
index 0000000..ab1e85a
--- /dev/null
+++ b/ui/src/main/js/components/UpdateStateMachine.js
@@ -0,0 +1,21 @@
+import React from 'react';
+
+import StateMachine from 'components/StateMachine';
+
+import { addClass } from 'utils/Common';
+import { UPDATE_STATUS } from 'utils/Thrift';
+import { getClassForUpdateStatus } from 'utils/Update';
+
+export default function UpdateStateMachine({ update }) {
+  const events = update.updateEvents;
+  const states = events.map((e, i) => ({
+    className: addClass(
+      getClassForUpdateStatus(e.status),
+      (i === events.length - 1) ? ' active' : ''),
+    state: UPDATE_STATUS[e.status],
+    message: e.message,
+    timestamp: e.timestampMs
+  }));
+  const className = getClassForUpdateStatus(events[events.length - 1].status);
+  return <StateMachine className={addClass('update-state-machine', className)} states={states} />;
+}

http://git-wip-us.apache.org/repos/asf/aurora/blob/4a1fba3c/ui/src/main/js/components/UpdateStatus.js
----------------------------------------------------------------------
diff --git a/ui/src/main/js/components/UpdateStatus.js b/ui/src/main/js/components/UpdateStatus.js
new file mode 100644
index 0000000..7d37430
--- /dev/null
+++ b/ui/src/main/js/components/UpdateStatus.js
@@ -0,0 +1,22 @@
+import React from 'react';
+
+import UpdateTime from 'components/UpdateTime';
+
+import { UPDATE_STATUS } from 'utils/Thrift';
+import { isInProgressUpdate } from 'utils/Update';
+
+export default function UpdateStatus({ update }) {
+  const time = isInProgressUpdate(update) ? '' : <UpdateTime update={update} />;
+  return (<div>
+    <div className='update-byline'>
+      <span>
+        Update started by <strong>{update.update.summary.user}</strong>
+      </span>
+      <span>&bull;</span>
+      <span>
+        Status: <strong>{UPDATE_STATUS[update.update.summary.state.status]}</strong>
+      </span>
+    </div>
+    {time}
+  </div>);
+}

http://git-wip-us.apache.org/repos/asf/aurora/blob/4a1fba3c/ui/src/main/js/components/UpdateTime.js
----------------------------------------------------------------------
diff --git a/ui/src/main/js/components/UpdateTime.js b/ui/src/main/js/components/UpdateTime.js
new file mode 100644
index 0000000..e55d376
--- /dev/null
+++ b/ui/src/main/js/components/UpdateTime.js
@@ -0,0 +1,33 @@
+import moment from 'moment';
+import React from 'react';
+
+import { RelativeTime } from 'components/Time';
+
+function UpdateTimeDisplay({ timestamp }) {
+  return (<div className='update-time'>
+    <span>{moment(timestamp).utc().format('ddd, MMM Do')}</span>
+    <h4>{moment(timestamp).utc().format('HH:mm')}</h4>
+    <span className='time-ago'><RelativeTime ts={timestamp} /></span>
+  </div>);
+};
+
+function UpdateDuration({ update }) {
+  const duration = (update.update.summary.state.lastModifiedTimestampMs -
+    update.update.summary.state.createdTimestampMs);
+  return <div className='update-duration'>Duration: {moment.duration(duration).humanize()}</div>;
+};
+
+function UpdateTimeRange({ update }) {
+  return (<div className='update-time-range'>
+    <UpdateTimeDisplay timestamp={update.update.summary.state.createdTimestampMs} />
+    <h5>~</h5>
+    <UpdateTimeDisplay timestamp={update.update.summary.state.lastModifiedTimestampMs} />
+  </div>);
+};
+
+export default function UpdateTime({ update }) {
+  return (<div>
+    <UpdateTimeRange update={update} />
+    <UpdateDuration update={update} />
+  </div>);
+}

http://git-wip-us.apache.org/repos/asf/aurora/blob/4a1fba3c/ui/src/main/js/components/UpdateTitle.js
----------------------------------------------------------------------
diff --git a/ui/src/main/js/components/UpdateTitle.js b/ui/src/main/js/components/UpdateTitle.js
new file mode 100644
index 0000000..cdeac6a
--- /dev/null
+++ b/ui/src/main/js/components/UpdateTitle.js
@@ -0,0 +1,26 @@
+import React from 'react';
+
+import { IconPanelTitle } from 'components/Layout';
+
+import { statusDispatcher } from 'utils/Update';
+
+const titleDispatch = {
+  success: (update) => {
+    return <IconPanelTitle className='success' icon='ok-sign' title='Update Successful' />;
+  },
+  warning: (update) => {
+    return <IconPanelTitle className='attention' icon='warning-sign' title='Update Paused' />;
+  },
+  error: (update) => {
+    return <IconPanelTitle className='error' icon='remove-sign' title='Update Failed' />;
+  },
+  inProgress: (update) => {
+    return <IconPanelTitle className='highlight' icon='play-circle' title='Update In Progress' />;
+  }
+};
+
+const titleDispatcher = statusDispatcher(titleDispatch);
+
+export default function UpdateTitle({ update }) {
+  return titleDispatcher(update);
+}

http://git-wip-us.apache.org/repos/asf/aurora/blob/4a1fba3c/ui/src/main/js/components/__tests__/InstanceViz-test.js
----------------------------------------------------------------------
diff --git a/ui/src/main/js/components/__tests__/InstanceViz-test.js b/ui/src/main/js/components/__tests__/InstanceViz-test.js
new file mode 100644
index 0000000..25efbf5
--- /dev/null
+++ b/ui/src/main/js/components/__tests__/InstanceViz-test.js
@@ -0,0 +1,41 @@
+import React from 'react';
+import { shallow } from 'enzyme';
+
+import InstanceViz from '../InstanceViz';
+
+import { range } from 'utils/Common';
+
+function generateInstances(n) {
+  return range(0, n - 1).map((i) => {
+    return {
+      className: 'okay',
+      instanceId: i,
+      title: `test-${i}`
+    };
+  });
+}
+
+const jobKey = {job: {role: 'test', environment: 'test', name: 'test'}};
+
+describe('InstanceViz', () => {
+  it('Should apply the small class to large numbers of instances', () => {
+    const el = shallow(<InstanceViz instances={generateInstances(1001)} jobKey={jobKey} />);
+    expect(el.find('ul.small').length).toBe(1);
+    expect(el.find('ul.medium').length).toBe(0);
+    expect(el.find('ul.big').length).toBe(0);
+  });
+
+  it('Should apply the medium class to medium numbers of instances', () => {
+    const el = shallow(<InstanceViz instances={generateInstances(101)} jobKey={jobKey} />);
+    expect(el.find('ul.small').length).toBe(0);
+    expect(el.find('ul.medium').length).toBe(1);
+    expect(el.find('ul.big').length).toBe(0);
+  });
+
+  it('Should apply the big class to small numbers of instances', () => {
+    const el = shallow(<InstanceViz instances={generateInstances(100)} jobKey={jobKey} />);
+    expect(el.find('ul.small').length).toBe(0);
+    expect(el.find('ul.medium').length).toBe(0);
+    expect(el.find('ul.big').length).toBe(1);
+  });
+});

http://git-wip-us.apache.org/repos/asf/aurora/blob/4a1fba3c/ui/src/main/js/components/__tests__/Pagination-test.js
----------------------------------------------------------------------
diff --git a/ui/src/main/js/components/__tests__/Pagination-test.js b/ui/src/main/js/components/__tests__/Pagination-test.js
index f2b72e9..8426527 100644
--- a/ui/src/main/js/components/__tests__/Pagination-test.js
+++ b/ui/src/main/js/components/__tests__/Pagination-test.js
@@ -53,6 +53,20 @@ describe('Pagination', () => {
       el.containsAllMatchingElements([<PageNavigation currentPage={1} numPages={1} />])).toBe(true);
   });
 
+  it('Should not show PageNavigation when hide single page is set', () => {
+    const el = shallow(
+      <Pagination data={data} hideIfSinglePage numberPerPage={25} renderer={render} />);
+    expect(el.find(Row).length).toBe(10);
+    expect(el.find(PageNavigation).length).toBe(0);
+  });
+
+  it('Should show PageNavigation when hide single page is set, but theres multiple pages', () => {
+    const el = shallow(
+      <Pagination data={data} hideIfSinglePage numberPerPage={2} renderer={render} />);
+    expect(el.find(Row).length).toBe(2);
+    expect(el.find(PageNavigation).length).toBe(1);
+  });
+
   it('Should sort correctly', () => {
     const el = shallow(
       <Pagination data={data} numberPerPage={3} renderer={render} sortBy='name' />);
@@ -75,6 +89,17 @@ describe('Pagination', () => {
       <PageNavigation currentPage={1} numPages={4} />])).toBe(true);
   });
 
+  it('Should respect natural order when sortBy is omitted', () => {
+    const el = shallow(
+      <Pagination data={data} numberPerPage={3} renderer={render} />);
+    expect(el.find(Row).length).toBe(3);
+    expect(el.containsAllMatchingElements([
+      <Row key={1} />,
+      <Row key={2} />,
+      <Row key={3} />,
+      <PageNavigation currentPage={1} numPages={4} />])).toBe(true);
+  });
+
   it('Should filter correctly', () => {
     const el = shallow(<Pagination
       data={data}

http://git-wip-us.apache.org/repos/asf/aurora/blob/4a1fba3c/ui/src/main/js/components/__tests__/UpdateInstanceEvents-test.js
----------------------------------------------------------------------
diff --git a/ui/src/main/js/components/__tests__/UpdateInstanceEvents-test.js b/ui/src/main/js/components/__tests__/UpdateInstanceEvents-test.js
new file mode 100644
index 0000000..4cbad09
--- /dev/null
+++ b/ui/src/main/js/components/__tests__/UpdateInstanceEvents-test.js
@@ -0,0 +1,40 @@
+import React from 'react';
+import { shallow } from 'enzyme';
+
+import Pagination from '../Pagination';
+import StateMachine from '../StateMachine';
+import UpdateInstanceEvents, { InstanceEvent } from '../UpdateInstanceEvents';
+
+import { InstanceUpdateEventBuilder, UpdateDetailsBuilder } from 'test-utils/UpdateBuilders';
+
+describe('UpdateInstanceEvents', () => {
+  it('Should reverse sort each instance by the latest instance event', () => {
+    const update = UpdateDetailsBuilder.instanceEvents([
+      InstanceUpdateEventBuilder.instanceId(0).timestampMs(1).build(),
+      InstanceUpdateEventBuilder.instanceId(1).timestampMs(0).build(),
+      InstanceUpdateEventBuilder.instanceId(2).timestampMs(0).build(),
+      InstanceUpdateEventBuilder.instanceId(2).timestampMs(2).build(),
+      InstanceUpdateEventBuilder.instanceId(0).timestampMs(5).build(),
+      InstanceUpdateEventBuilder.instanceId(3).timestampMs(0).build(),
+      InstanceUpdateEventBuilder.instanceId(3).timestampMs(20).build(),
+      InstanceUpdateEventBuilder.instanceId(4).timestampMs(3).build()]).build();
+
+    const el = shallow(<UpdateInstanceEvents update={update} />);
+    expect(el.find(Pagination).first().props().data).toEqual([3, 0, 4, 2, 1]);
+  });
+});
+
+describe('InstanceEvent', () => {
+  const jobKey = {job: {role: 'role', environment: 'env', name: 'name'}};
+
+  it('Should support expand toggle', () => {
+    const events = [
+      InstanceUpdateEventBuilder.instanceId(0).timestampMs(1).build(),
+      InstanceUpdateEventBuilder.instanceId(0).timestampMs(5).build()];
+
+    const el = shallow(<InstanceEvent events={events} instanceId={0} jobKey={jobKey} />);
+    expect(el.find(StateMachine).length).toBe(0);
+    el.find('.update-instance-event').simulate('click');
+    expect(el.find(StateMachine).length).toBe(1);
+  });
+});

http://git-wip-us.apache.org/repos/asf/aurora/blob/4a1fba3c/ui/src/main/js/components/__tests__/UpdateList-test.js
----------------------------------------------------------------------
diff --git a/ui/src/main/js/components/__tests__/UpdateList-test.js b/ui/src/main/js/components/__tests__/UpdateList-test.js
new file mode 100644
index 0000000..584df9d
--- /dev/null
+++ b/ui/src/main/js/components/__tests__/UpdateList-test.js
@@ -0,0 +1,17 @@
+import React from 'react';
+import { shallow } from 'enzyme';
+
+import Loading from '../Loading';
+import UpdateList from '../UpdateList';
+
+describe('UpdateList', () => {
+  it('Handles null by showing Loading', () => {
+    const el = shallow(<UpdateList />);
+    expect(el.contains(<Loading />)).toBe(true);
+  });
+
+  it('Does not show loading when data is passed, even an empty list', () => {
+    const el = shallow(<UpdateList updates={[]} />);
+    expect(el.contains(<Loading />)).toBe(false);
+  });
+});

http://git-wip-us.apache.org/repos/asf/aurora/blob/4a1fba3c/ui/src/main/js/components/__tests__/UpdateStatus-test.js
----------------------------------------------------------------------
diff --git a/ui/src/main/js/components/__tests__/UpdateStatus-test.js b/ui/src/main/js/components/__tests__/UpdateStatus-test.js
new file mode 100644
index 0000000..0bd14b2
--- /dev/null
+++ b/ui/src/main/js/components/__tests__/UpdateStatus-test.js
@@ -0,0 +1,23 @@
+import React from 'react';
+import { shallow } from 'enzyme';
+
+import UpdateStatus from '../UpdateStatus';
+import UpdateTime from '../UpdateTime';
+
+import { builderWithStatus } from 'test-utils/UpdateBuilders';
+
+describe('UpdateStatus', () => {
+  it('Should show UpdateTime when update terminal', () => {
+    const update = builderWithStatus(JobUpdateStatus.ROLLED_FORWARD).build();
+
+    const el = shallow(<UpdateStatus update={update} />);
+    expect(el.contains(<UpdateTime update={update} />)).toBe(true);
+  });
+
+  it('Should NOT show UpdateTime when update in-progress', () => {
+    const update = builderWithStatus(JobUpdateStatus.ROLLING_FORWARD).build();
+
+    const el = shallow(<UpdateStatus update={update} />);
+    expect(el.find(UpdateTime).length).toBe(0);
+  });
+});

http://git-wip-us.apache.org/repos/asf/aurora/blob/4a1fba3c/ui/src/main/js/index.js
----------------------------------------------------------------------
diff --git a/ui/src/main/js/index.js b/ui/src/main/js/index.js
index 8f07734..30646f7 100644
--- a/ui/src/main/js/index.js
+++ b/ui/src/main/js/index.js
@@ -7,6 +7,8 @@ import Navigation from 'components/Navigation';
 import Home from 'pages/Home';
 import Instance from 'pages/Instance';
 import Jobs from 'pages/Jobs';
+import Update from 'pages/Update';
+import Updates from 'pages/Updates';
 
 import styles from '../sass/app.scss'; // eslint-disable-line no-unused-vars
 
@@ -24,8 +26,11 @@ const SchedulerUI = () => (
         component={injectApi(Instance)}
         exact
         path='/beta/scheduler/:role/:environment/:name/:instance' />
-      <Route component={Home} exact path='/beta/scheduler/:role/:environment/:name/update/:uid' />
-      <Route component={Home} exact path='/beta/updates' />
+      <Route
+        component={injectApi(Update)}
+        exact
+        path='/beta/scheduler/:role/:environment/:name/update/:uid' />
+      <Route component={injectApi(Updates)} exact path='/beta/updates' />
     </div>
   </Router>
 );

http://git-wip-us.apache.org/repos/asf/aurora/blob/4a1fba3c/ui/src/main/js/pages/Update.js
----------------------------------------------------------------------
diff --git a/ui/src/main/js/pages/Update.js b/ui/src/main/js/pages/Update.js
new file mode 100644
index 0000000..c900269
--- /dev/null
+++ b/ui/src/main/js/pages/Update.js
@@ -0,0 +1,54 @@
+import React from 'react';
+
+import Breadcrumb from 'components/Breadcrumb';
+import Loading from 'components/Loading';
+import UpdateConfig from 'components/UpdateConfig';
+import UpdateDetails from 'components/UpdateDetails';
+
+export default class Update extends React.Component {
+  constructor(props) {
+    super(props);
+    this.state = { loading: true };
+  }
+
+  componentWillMount() {
+    const { role, environment, name, uid } = this.props.match.params;
+
+    const job = new JobKey();
+    job.role = role;
+    job.environment = environment;
+    job.name = name;
+
+    const key = new JobUpdateKey();
+    key.job = job;
+    key.id = uid;
+
+    const query = new JobUpdateQuery();
+    query.key = key;
+
+    const that = this;
+    this.props.api.getJobUpdateDetails(null, query, (response) => {
+      const update = response.result.getJobUpdateDetailsResult.detailsList[0];
+      that.setState({ cluster: response.serverInfo.clusterName, loading: false, update });
+    });
+  }
+
+  render() {
+    const { role, environment, name, uid } = this.props.match.params;
+
+    if (this.state.loading) {
+      return <Loading />;
+    }
+
+    return (<div className='update-page'>
+      <Breadcrumb
+        cluster={this.state.cluster}
+        env={environment}
+        name={name}
+        role={role}
+        update={uid} />
+      <UpdateDetails update={this.state.update} />
+      <UpdateConfig update={this.state.update} />
+    </div>);
+  }
+}

http://git-wip-us.apache.org/repos/asf/aurora/blob/4a1fba3c/ui/src/main/js/pages/Updates.js
----------------------------------------------------------------------
diff --git a/ui/src/main/js/pages/Updates.js b/ui/src/main/js/pages/Updates.js
new file mode 100644
index 0000000..44b9a11
--- /dev/null
+++ b/ui/src/main/js/pages/Updates.js
@@ -0,0 +1,66 @@
+import React from 'react';
+
+import Breadcrumb from 'components/Breadcrumb';
+import PanelGroup, { Container, StandardPanelTitle } from 'components/Layout';
+import UpdateList from 'components/UpdateList';
+
+import { getInProgressStates, getTerminalStates } from 'utils/Update';
+
+export const MAX_QUERY_SIZE = 100;
+
+export class UpdatesFetcher extends React.Component {
+  constructor(props) {
+    super(props);
+    this.state = { updates: null };
+  }
+
+  componentWillMount() {
+    const that = this;
+    const query = new JobUpdateQuery();
+    query.updateStatuses = this.props.states;
+    query.limit = MAX_QUERY_SIZE;
+    this.props.api.getJobUpdateSummaries(query, (response) => {
+      const updates = response.result.getJobUpdateSummariesResult.updateSummaries;
+      that.setState({updates});
+      that.props.clusterFn(response.serverInfo.clusterName);
+    });
+  }
+
+  render() {
+    return (<Container>
+      <PanelGroup noPadding title={<StandardPanelTitle title={this.props.title} />}>
+        <UpdateList updates={this.state.updates} />
+      </PanelGroup>
+    </Container>);
+  }
+}
+
+export default class Updates extends React.Component {
+  constructor(props) {
+    super(props);
+    this.state = { cluster: null };
+    this.clusterFn = this.setCluster.bind(this);
+  }
+
+  setCluster(cluster) {
+    // TODO(dmcg): We should just have the Scheduler return the cluster as a global.
+    this.setState({cluster});
+  }
+
+  render() {
+    const api = this.props.api;
+    return (<div className='update-page'>
+      <Breadcrumb cluster={this.state.cluster} />
+      <UpdatesFetcher
+        api={api}
+        clusterFn={this.clusterFn}
+        states={getInProgressStates()}
+        title='Updates In Progress' />
+      <UpdatesFetcher
+        api={api}
+        clusterFn={this.clusterFn}
+        states={getTerminalStates()}
+        title='Recently Completed Updates' />
+    </div>);
+  }
+}

http://git-wip-us.apache.org/repos/asf/aurora/blob/4a1fba3c/ui/src/main/js/pages/__tests__/Update-test.js
----------------------------------------------------------------------
diff --git a/ui/src/main/js/pages/__tests__/Update-test.js b/ui/src/main/js/pages/__tests__/Update-test.js
new file mode 100644
index 0000000..570a999
--- /dev/null
+++ b/ui/src/main/js/pages/__tests__/Update-test.js
@@ -0,0 +1,56 @@
+import React from 'react';
+import { shallow } from 'enzyme';
+
+import Update from '../Update';
+
+import Breadcrumb from 'components/Breadcrumb';
+import Loading from 'components/Loading';
+import UpdateConfig from 'components/UpdateConfig';
+import UpdateDetails from 'components/UpdateDetails';
+
+const TEST_CLUSTER = 'test-cluster';
+
+const params = {
+  role: 'test-role',
+  environment: 'test-env',
+  name: 'test-job',
+  instance: '1',
+  uid: 'update-id'
+};
+
+function createMockApi(update) {
+  const api = {};
+  api.getJobUpdateDetails = (id, query, handler) => handler({
+    result: {
+      getJobUpdateDetailsResult: {
+        detailsList: [update]
+      }
+    },
+    serverInfo: {
+      clusterName: TEST_CLUSTER
+    }
+  });
+  return api;
+}
+
+const update = {}; // only testing pass-through here...
+
+describe('Update', () => {
+  it('Should render Loading before data is fetched', () => {
+    expect(shallow(<Update
+      api={{getJobUpdateDetails: () => {}}}
+      match={{params: params}} />).contains(<Loading />)).toBe(true);
+  });
+
+  it('Should render page elements when update is fetched', () => {
+    const el = shallow(<Update api={createMockApi(update)} match={{params: params}} />);
+    expect(el.contains(<Breadcrumb
+      cluster={TEST_CLUSTER}
+      env={params.environment}
+      name={params.name}
+      role={params.role}
+      update={params.uid} />)).toBe(true);
+    expect(el.contains(<UpdateConfig update={update} />)).toBe(true);
+    expect(el.contains(<UpdateDetails update={update} />)).toBe(true);
+  });
+});

http://git-wip-us.apache.org/repos/asf/aurora/blob/4a1fba3c/ui/src/main/js/pages/__tests__/Updates-test.js
----------------------------------------------------------------------
diff --git a/ui/src/main/js/pages/__tests__/Updates-test.js b/ui/src/main/js/pages/__tests__/Updates-test.js
new file mode 100644
index 0000000..8cc3315
--- /dev/null
+++ b/ui/src/main/js/pages/__tests__/Updates-test.js
@@ -0,0 +1,33 @@
+import React from 'react';
+import { shallow } from 'enzyme';
+
+import { UpdatesFetcher } from '../Updates';
+import UpdateList from 'components/UpdateList';
+
+const TEST_CLUSTER = 'test-cluster';
+
+function createMockApi(updates) {
+  const api = {};
+  api.getJobUpdateSummaries = (query, handler) => handler({
+    result: {
+      getJobUpdateSummariesResult: {
+        updateSummaries: updates
+      }
+    },
+    serverInfo: {
+      clusterName: TEST_CLUSTER
+    }
+  });
+  return api;
+}
+
+const updates = [{}]; // only testing pass-through here...
+const states = [];
+
+describe('UpdatesFetcher', () => {
+  it('Should render update list with updates when mounted', () => {
+    const el = shallow(
+      <UpdatesFetcher api={createMockApi(updates)} clusterFn={() => {}} states={states} />);
+    expect(el.contains(<UpdateList updates={updates} />));
+  });
+});

http://git-wip-us.apache.org/repos/asf/aurora/blob/4a1fba3c/ui/src/main/js/test-utils/UpdateBuilders.js
----------------------------------------------------------------------
diff --git a/ui/src/main/js/test-utils/UpdateBuilders.js b/ui/src/main/js/test-utils/UpdateBuilders.js
new file mode 100644
index 0000000..2764f0e
--- /dev/null
+++ b/ui/src/main/js/test-utils/UpdateBuilders.js
@@ -0,0 +1,86 @@
+import createBuilder from 'test-utils/Builder';
+
+import { TaskConfigBuilder } from './TaskBuilders';
+
+const USER = 'update-user';
+const UPDATE_ID = 'update-id';
+const JOB_KEY = {
+  role: 'test-role',
+  environment: 'test-env',
+  name: 'test-name'
+};
+const UPDATE_KEY = {
+  job: JOB_KEY,
+  id: UPDATE_ID
+};
+
+export default {
+  USER
+};
+
+export const UpdateSettingsBuilder = createBuilder({
+  updateGroupSize: 1,
+  maxPerInstanceFailures: 0,
+  maxFailedInstances: 0,
+  minWaitInInstanceRunningMs: 1,
+  rollbackOnFailure: true,
+  updateOnlyTheseInstances: [],
+  waitForBatchCompletion: false
+});
+
+export const UpdateEventBuilder = createBuilder({
+  status: JobUpdateStatus.ROLLING_FORWARD,
+  timestampMs: 0,
+  user: USER,
+  message: ''
+});
+
+export const InstanceUpdateEventBuilder = createBuilder({
+  instanceId: 0,
+  timestampMs: 0,
+  action: JobUpdateAction.INSTANCE_UPDATING
+});
+
+export const InstanceTaskConfigBuilder = createBuilder({
+  task: TaskConfigBuilder.build(),
+  instances: [{first: 0, last: 0}]
+});
+
+export const UpdateInstructionsBuilder = createBuilder({
+  initialState: [InstanceTaskConfigBuilder.build()],
+  desiredState: InstanceTaskConfigBuilder.task(
+    TaskConfigBuilder.resources([{numCpus: 2, ramMb: 2048, diskMb: 2048}])).build(),
+  settings: UpdateSettingsBuilder.build()
+});
+
+export const UpdateStateBuilder = createBuilder({
+  status: JobUpdateStatus.ROLLING_FORWARD,
+  createdTimestampMs: 0,
+  lastModifiedTimestampMs: 60000
+});
+
+export const UpdateSummaryBuilder = createBuilder({
+  key: UPDATE_KEY,
+  user: USER,
+  state: UpdateStateBuilder.build(),
+  metadata: []
+});
+
+export const UpdateBuilder = createBuilder({
+  summary: UpdateSummaryBuilder.build(),
+  instructions: UpdateInstructionsBuilder.build()
+});
+
+export const UpdateDetailsBuilder = createBuilder({
+  update: UpdateBuilder.build(),
+  updateEvents: [UpdateEventBuilder.build()],
+  instanceEvents: [InstanceUpdateEventBuilder.build()]
+});
+
+export function builderWithStatus(updateStatus) {
+  return UpdateDetailsBuilder.update(
+    UpdateBuilder.summary(
+      UpdateSummaryBuilder.state(UpdateStateBuilder.status(updateStatus).build()).build()
+    ).build()
+  ).updateEvents([UpdateEventBuilder.status(updateStatus).build()]);
+}

http://git-wip-us.apache.org/repos/asf/aurora/blob/4a1fba3c/ui/src/main/js/utils/Common.js
----------------------------------------------------------------------
diff --git a/ui/src/main/js/utils/Common.js b/ui/src/main/js/utils/Common.js
index be8766c..8f2da7c 100644
--- a/ui/src/main/js/utils/Common.js
+++ b/ui/src/main/js/utils/Common.js
@@ -20,3 +20,19 @@ export function addClass(original, maybeClass) {
 export function clone(obj) {
   return JSON.parse(JSON.stringify(obj));
 }
+
+export function sort(arr, prop, reverse = false) {
+  return arr.sort((a, b) => {
+    if (prop(a) === prop(b)) {
+      return 0;
+    }
+    if (prop(a) < prop(b)) {
+      return reverse ? 1 : -1;
+    }
+    return reverse ? -1 : 1;
+  });
+}
+
+export function range(start, end) {
+  return [...Array(1 + end - start).keys()].map((i) => start + i);
+}

http://git-wip-us.apache.org/repos/asf/aurora/blob/4a1fba3c/ui/src/main/js/utils/Thrift.js
----------------------------------------------------------------------
diff --git a/ui/src/main/js/utils/Thrift.js b/ui/src/main/js/utils/Thrift.js
index b247e36..4336bd1 100644
--- a/ui/src/main/js/utils/Thrift.js
+++ b/ui/src/main/js/utils/Thrift.js
@@ -1,6 +1,8 @@
 import { invert } from 'utils/Common';
 
 export const SCHEDULE_STATUS = invert(ScheduleStatus);
+export const UPDATE_STATUS = invert(JobUpdateStatus);
+export const UPDATE_ACTION = invert(JobUpdateAction);
 
 export const OKAY_SCHEDULE_STATUS = [
   ScheduleStatus.RUNNING,
@@ -25,9 +27,48 @@ export const ERROR_SCHEDULE_STATUS = [
   ScheduleStatus.FAILED
 ];
 
+export const OKAY_UPDATE_STATUS = [
+  JobUpdateStatus.ROLLED_FORWARD
+];
+
+export const WARNING_UPDATE_STATUS = [
+  JobUpdateStatus.ROLL_FORWARD_AWAITING_PULSE,
+  JobUpdateStatus.ROLL_FORWARD_PAUSED
+];
+
+export const ERROR_UPDATE_STATUS = [
+  JobUpdateStatus.ROLLING_BACK,
+  JobUpdateStatus.ROLLED_BACK,
+  JobUpdateStatus.ROLL_BACK_PAUSED,
+  JobUpdateStatus.ABORTED,
+  JobUpdateStatus.ERROR,
+  JobUpdateStatus.FAILED,
+  JobUpdateStatus.ROLL_BACK_AWAITING_PULSE
+];
+
+export const OKAY_UPDATE_ACTION = [
+  JobUpdateAction.INSTANCE_UPDATED
+];
+
+export const WARNING_UPDATE_ACTION = [
+  JobUpdateAction.INSTANCE_ROLLING_BACK,
+  JobUpdateAction.INSTANCE_ROLLED_BACK
+];
+
+export const ERROR_UPDATE_ACTION = [
+  JobUpdateAction.INSTANCE_UPDATE_FAILED,
+  JobUpdateAction.INSTANCE_ROLLBACK_FAILED
+];
+
 export default {
   OKAY_SCHEDULE_STATUS,
   WARNING_SCHEDULE_STATUS,
   ERROR_SCHEDULE_STATUS,
-  USER_WAIT_SCHEDULE_STATUS
+  USER_WAIT_SCHEDULE_STATUS,
+  OKAY_UPDATE_STATUS,
+  WARNING_UPDATE_STATUS,
+  ERROR_UPDATE_STATUS,
+  OKAY_UPDATE_ACTION,
+  WARNING_UPDATE_ACTION,
+  ERROR_UPDATE_ACTION
 };

http://git-wip-us.apache.org/repos/asf/aurora/blob/4a1fba3c/ui/src/main/js/utils/Update.js
----------------------------------------------------------------------
diff --git a/ui/src/main/js/utils/Update.js b/ui/src/main/js/utils/Update.js
new file mode 100644
index 0000000..dee2393
--- /dev/null
+++ b/ui/src/main/js/utils/Update.js
@@ -0,0 +1,163 @@
+import { sort } from 'utils/Common';
+import Thrift, { UPDATE_ACTION } from 'utils/Thrift';
+
+export function isSuccessfulUpdate(update) {
+  return update.update.summary.state.status === JobUpdateStatus.ROLLED_FORWARD;
+}
+
+export function isInProgressUpdate(update) {
+  return update.update.summary.state.status === JobUpdateStatus.ROLLING_FORWARD;
+}
+
+function processInstanceIdsFromRanges(ranges, fn) {
+  ranges.forEach((r) => {
+    for (let i = r.first; i <= r.last; i++) {
+      fn(i);
+    }
+  });
+}
+
+function getAllInstanceIds(update) {
+  const allIds = {};
+  const newIds = {};
+  const oldIds = {};
+
+  processInstanceIdsFromRanges(update.instructions.desiredState.instances, (id) => {
+    newIds[id] = true;
+    allIds[id] = true;
+  });
+
+  update.instructions.initialState.forEach((task) => {
+    processInstanceIdsFromRanges(task.instances, (id) => {
+      oldIds[id] = true;
+      allIds[id] = true;
+    });
+  });
+  return { allIds, newIds, oldIds };
+}
+
+function getLatestInstanceEvents(instanceEvents, predicate = (e) => true) {
+  const events = sort(instanceEvents, (e) => e.timestampMs, true);
+  const instanceMap = {};
+  events.forEach((e) => {
+    if (!instanceMap.hasOwnProperty(e.instanceId) && predicate(e)) {
+      instanceMap[e.instanceId] = e;
+    }
+  });
+  return instanceMap;
+}
+
+export function instanceSummary(details) {
+  const instances = getAllInstanceIds(details.update);
+  const latestInstanceEvents = getLatestInstanceEvents(details.instanceEvents);
+  const allIds = Object.keys(instances.allIds);
+
+  return allIds.map((i) => {
+    // If there is an event, use the event to generate the instance status.
+    if (latestInstanceEvents.hasOwnProperty(i)) {
+      const latestEvent = latestInstanceEvents[i];
+      // If instance has been updated and is in initial state, but not in desired state,
+      // then it's a removed instance.
+      if (latestEvent.action === JobUpdateAction.INSTANCE_UPDATED &&
+          instances.oldIds.hasOwnProperty(i) &&
+          !instances.newIds.hasOwnProperty(i)) {
+        return {
+          instanceId: i,
+          className: 'removed',
+          title: 'Instance Removed'
+        };
+      }
+
+      // Normal case - the latest action is the current status
+      return {
+        instanceId: i,
+        className: getClassForUpdateAction(latestEvent.action),
+        title: UPDATE_ACTION[latestEvent.action]
+      };
+    } else {
+      // No event, so it's a pending instance.
+      return {
+        instanceId: i,
+        className: 'pending',
+        title: 'Pending'
+      };
+    }
+  });
+}
+
+function progressFromEvents(instanceEvents) {
+  const success = getLatestInstanceEvents(instanceEvents,
+    (e) => e.action === JobUpdateAction.INSTANCE_UPDATED);
+  return Object.keys(success).length;
+}
+
+export function updateStats(details) {
+  const allInstances = Object.keys(getAllInstanceIds(details.update).allIds);
+  const totalInstancesToBeUpdated = allInstances.length;
+  const instancesUpdated = progressFromEvents(details.instanceEvents);
+  const progress = Math.round((instancesUpdated / totalInstancesToBeUpdated) * 100);
+  return {
+    totalInstancesToBeUpdated,
+    instancesUpdated,
+    progress
+  };
+}
+
+export function getInProgressStates() {
+  return ACTIVE_JOB_UPDATE_STATES;
+}
+
+export function getTerminalStates() {
+  const active = new Set(ACTIVE_JOB_UPDATE_STATES);
+  return Object.values(JobUpdateStatus).filter((k) => !active.has(k));
+}
+
+export function getClassForUpdateStatus(status) {
+  if (Thrift.OKAY_UPDATE_STATUS.includes(status)) {
+    return 'okay';
+  } else if (Thrift.WARNING_UPDATE_STATUS.includes(status)) {
+    return 'attention';
+  } else if (Thrift.ERROR_UPDATE_STATUS.includes(status)) {
+    return 'error';
+  }
+  return 'in-progress';
+}
+
+export function getClassForUpdateAction(action) {
+  if (Thrift.OKAY_UPDATE_ACTION.includes(action)) {
+    return 'okay';
+  } else if (Thrift.WARNING_UPDATE_ACTION.includes(action)) {
+    return 'attention';
+  } else if (Thrift.ERROR_UPDATE_ACTION.includes(action)) {
+    return 'error';
+  }
+  return 'in-progress';
+}
+
+export function statusDispatcher(dispatch) {
+  return (update) => {
+    const status = update.update.summary.state.status;
+    if (Thrift.OKAY_UPDATE_STATUS.includes(status)) {
+      return dispatch.success(update);
+    } else if (Thrift.WARNING_UPDATE_STATUS.includes(status)) {
+      return dispatch.warning(update);
+    } else if (Thrift.ERROR_UPDATE_STATUS.includes(status)) {
+      return dispatch.error(update);
+    }
+    return dispatch.inProgress(update);
+  };
+}
+
+export function actionDispatcher(dispatch) {
+  return (event) => {
+    const action = event.action;
+    if (Thrift.OKAY_UPDATE_ACTION.includes(action)) {
+      return dispatch.success(event);
+    } else if (Thrift.WARNING_UPDATE_ACTION.includes(action)) {
+      return dispatch.warning(event);
+    } else if (Thrift.ERROR_UPDATE_ACTION.includes(action)) {
+      return dispatch.error(event);
+    }
+    return dispatch.inProgress(event);
+  };
+}

http://git-wip-us.apache.org/repos/asf/aurora/blob/4a1fba3c/ui/src/main/js/utils/__tests__/Update-test.js
----------------------------------------------------------------------
diff --git a/ui/src/main/js/utils/__tests__/Update-test.js b/ui/src/main/js/utils/__tests__/Update-test.js
new file mode 100644
index 0000000..88fa5f7
--- /dev/null
+++ b/ui/src/main/js/utils/__tests__/Update-test.js
@@ -0,0 +1,291 @@
+import {
+  actionDispatcher,
+  getClassForUpdateStatus,
+  getClassForUpdateAction,
+  instanceSummary,
+  statusDispatcher,
+  updateStats
+} from '../Update';
+
+import {
+  InstanceTaskConfigBuilder,
+  InstanceUpdateEventBuilder,
+  UpdateBuilder,
+  UpdateDetailsBuilder,
+  UpdateInstructionsBuilder,
+  builderWithStatus } from 'test-utils/UpdateBuilders';
+import {
+  ERROR_UPDATE_ACTION,
+  ERROR_UPDATE_STATUS,
+  WARNING_UPDATE_ACTION,
+  WARNING_UPDATE_STATUS } from 'utils/Thrift';
+
+function createDispatchSpies() {
+  return {
+    error: jest.fn(),
+    inProgress: jest.fn(),
+    success: jest.fn(),
+    warning: jest.fn()
+  };
+}
+
+function assertSpies(spies, expect) {
+  return ['error', 'inProgress', 'success', 'warning'].every((key) => {
+    const expected = expect[key] || 0;
+    return spies[key].mock.calls.length === expected;
+  });
+}
+
+describe('actionDispatcher', () => {
+  it('Should dispatch success events', () => {
+    const spies = createDispatchSpies();
+    const dispatcher = actionDispatcher(spies);
+    dispatcher(InstanceUpdateEventBuilder.action(JobUpdateAction.INSTANCE_UPDATED).build());
+    expect(assertSpies(spies, {success: 1})).toBe(true);
+  });
+
+  it('Should dispatch in-progress events', () => {
+    const spies = createDispatchSpies();
+    const dispatcher = actionDispatcher(spies);
+    dispatcher(InstanceUpdateEventBuilder.action(JobUpdateAction.INSTANCE_UPDATING).build());
+    expect(assertSpies(spies, {inProgress: 1})).toBe(true);
+  });
+
+  it('Should dispatch warning events', () => {
+    WARNING_UPDATE_ACTION.forEach((action) => {
+      const spies = createDispatchSpies();
+      const dispatcher = actionDispatcher(spies);
+      dispatcher(InstanceUpdateEventBuilder.action(action).build());
+      expect(assertSpies(spies, {warning: 1})).toBe(true);
+    });
+  });
+
+  it('Should dispatch error events', () => {
+    ERROR_UPDATE_ACTION.forEach((action) => {
+      const spies = createDispatchSpies();
+      const dispatcher = actionDispatcher(spies);
+      dispatcher(InstanceUpdateEventBuilder.action(action).build());
+      expect(assertSpies(spies, {error: 1})).toBe(true);
+    });
+  });
+});
+
+describe('getClassForUpdateAction', () => {
+  it('Should return okay class', () => {
+    expect(getClassForUpdateAction(JobUpdateAction.INSTANCE_UPDATED)).toBe('okay');
+  });
+
+  it('Should return in-progress class', () => {
+    expect(getClassForUpdateAction(JobUpdateAction.INSTANCE_UPDATING)).toBe('in-progress');
+  });
+
+  it('Should dispatch warning events', () => {
+    WARNING_UPDATE_ACTION.forEach((action) => {
+      expect(getClassForUpdateAction(action)).toBe('attention');
+    });
+  });
+
+  it('Should dispatch error events', () => {
+    ERROR_UPDATE_ACTION.forEach((action) => {
+      expect(getClassForUpdateAction(action)).toBe('error');
+    });
+  });
+});
+
+describe('getClassForUpdateStatus', () => {
+  it('Should return okay for successful updates', () => {
+    expect(getClassForUpdateStatus(JobUpdateStatus.ROLLED_FORWARD)).toBe('okay');
+  });
+
+  it('Should fire the in-progress callback for rolling forward updates', () => {
+    expect(getClassForUpdateStatus(JobUpdateStatus.ROLLING_FORWARD)).toBe('in-progress');
+  });
+
+  it('Should fire the error callback for all failed updates', () => {
+    ERROR_UPDATE_STATUS.forEach((status) => {
+      expect(getClassForUpdateStatus(status)).toBe('error');
+    });
+  });
+
+  it('Should fire the warning callback for all failed updates', () => {
+    WARNING_UPDATE_STATUS.forEach((status) => {
+      expect(getClassForUpdateStatus(status)).toBe('attention');
+    });
+  });
+});
+
+describe('instanceSummary', () => {
+  const instanceUpdated = InstanceUpdateEventBuilder.action(JobUpdateAction.INSTANCE_UPDATED);
+
+  it('Should return the correct data', () => {
+    const instructions = UpdateInstructionsBuilder
+      .initialState([InstanceTaskConfigBuilder.instances([{first: 0, last: 10}]).build()])
+      .desiredState(InstanceTaskConfigBuilder.instances([{first: 0, last: 9}]).build())
+      .build();
+
+    const update = UpdateDetailsBuilder
+      .update(UpdateBuilder.instructions(instructions).build())
+      .instanceEvents([
+        InstanceUpdateEventBuilder.action(JobUpdateAction.INSTANCE_UPDATE_FAILED).build(),
+        instanceUpdated.instanceId(0).timestampMs(1).build(),
+        instanceUpdated.instanceId(2).build(),
+        instanceUpdated.instanceId(3).build(),
+        instanceUpdated.instanceId(5).build(),
+        instanceUpdated.instanceId(9).build()
+      ])
+      .build();
+    const summary = instanceSummary(update);
+    expect(summary).toEqual([
+      {instanceId: '0', className: 'okay', title: 'INSTANCE_UPDATED'},
+      {instanceId: '1', className: 'pending', title: 'Pending'},
+      {instanceId: '2', className: 'okay', title: 'INSTANCE_UPDATED'},
+      {instanceId: '3', className: 'okay', title: 'INSTANCE_UPDATED'},
+      {instanceId: '4', className: 'pending', title: 'Pending'},
+      {instanceId: '5', className: 'okay', title: 'INSTANCE_UPDATED'},
+      {instanceId: '6', className: 'pending', title: 'Pending'},
+      {instanceId: '7', className: 'pending', title: 'Pending'},
+      {instanceId: '8', className: 'pending', title: 'Pending'},
+      {instanceId: '9', className: 'okay', title: 'INSTANCE_UPDATED'},
+      {instanceId: '10', className: 'pending', title: 'Pending'}
+    ]);
+  });
+
+  it('Should handle removed instances', () => {
+    const instructions = UpdateInstructionsBuilder
+      .initialState([InstanceTaskConfigBuilder.instances([{first: 0, last: 3}]).build()])
+      .desiredState(InstanceTaskConfigBuilder.instances([{first: 0, last: 2}]).build())
+      .build();
+
+    const update = UpdateDetailsBuilder
+      .update(UpdateBuilder.instructions(instructions).build())
+      .instanceEvents([
+        InstanceUpdateEventBuilder.action(JobUpdateAction.INSTANCE_UPDATE_FAILED).build(),
+        InstanceUpdateEventBuilder.instanceId(2)
+          .action(JobUpdateAction.INSTANCE_ROLLED_BACK)
+          .build(),
+        instanceUpdated.instanceId(3).build()
+      ])
+      .build();
+    const summary = instanceSummary(update);
+    expect(summary).toEqual([
+      {instanceId: '0', className: 'error', title: 'INSTANCE_UPDATE_FAILED'},
+      {instanceId: '1', className: 'pending', title: 'Pending'},
+      {instanceId: '2', className: 'attention', title: 'INSTANCE_ROLLED_BACK'},
+      {instanceId: '3', className: 'removed', title: 'Instance Removed'}
+    ]);
+  });
+});
+
+describe('statusDispatcher', () => {
+  it('Should fire the success callback for successful updates', () => {
+    const spies = createDispatchSpies();
+    const dispatcher = statusDispatcher(spies);
+    dispatcher(builderWithStatus(JobUpdateStatus.ROLLED_FORWARD).build());
+    expect(assertSpies(spies, {success: 1})).toBe(true);
+  });
+
+  it('Should fire the in-progress callback for rolling forward updates', () => {
+    const spies = createDispatchSpies();
+    const dispatcher = statusDispatcher(spies);
+    dispatcher(builderWithStatus(JobUpdateStatus.ROLLING_FORWARD).build());
+    expect(assertSpies(spies, {inProgress: 1})).toBe(true);
+  });
+
+  it('Should fire the error callback for all failed updates', () => {
+    ERROR_UPDATE_STATUS.forEach((status) => {
+      const spies = createDispatchSpies();
+      const dispatcher = statusDispatcher(spies);
+      dispatcher(builderWithStatus(status).build());
+      expect(assertSpies(spies, {error: 1})).toBe(true);
+    });
+  });
+
+  it('Should fire the warning callback for all failed updates', () => {
+    WARNING_UPDATE_STATUS.forEach((status) => {
+      const spies = createDispatchSpies();
+      const dispatcher = statusDispatcher(spies);
+      dispatcher(builderWithStatus(status).build());
+      expect(assertSpies(spies, {warning: 1})).toBe(true);
+    });
+  });
+});
+
+describe('updateStats', () => {
+  const instanceUpdated = InstanceUpdateEventBuilder.action(JobUpdateAction.INSTANCE_UPDATED);
+
+  it('Should return the correct stats for a job with some instances to be updated', () => {
+    const instructions = UpdateInstructionsBuilder
+      .initialState([InstanceTaskConfigBuilder.instances([{first: 0, last: 9}]).build()])
+      .desiredState(InstanceTaskConfigBuilder.instances([{first: 0, last: 9}]).build())
+      .build();
+
+    const update = UpdateDetailsBuilder
+      .update(UpdateBuilder.instructions(instructions).build())
+      .instanceEvents([
+        InstanceUpdateEventBuilder.action(JobUpdateAction.INSTANCE_UPDATE_FAILED).build(),
+        instanceUpdated.instanceId(0).build(),
+        instanceUpdated.instanceId(2).build(),
+        instanceUpdated.instanceId(3).build(),
+        instanceUpdated.instanceId(5).build(),
+        instanceUpdated.instanceId(9).build()
+      ])
+      .build();
+    const stats = updateStats(update);
+    expect(stats).toEqual({totalInstancesToBeUpdated: 10, instancesUpdated: 5, progress: 50});
+  });
+
+  it('Should respect added instances', () => {
+    const instructions = UpdateInstructionsBuilder
+      .initialState([InstanceTaskConfigBuilder.instances([{first: 0, last: 4}]).build()])
+      .desiredState(InstanceTaskConfigBuilder.instances([{first: 0, last: 9}]).build())
+      .build();
+
+    const update = UpdateDetailsBuilder
+      .update(UpdateBuilder.instructions(instructions).build())
+      .instanceEvents([
+        instanceUpdated.instanceId(7).build(),
+        instanceUpdated.instanceId(8).build()
+      ])
+      .build();
+    const stats = updateStats(update);
+    expect(stats).toEqual({totalInstancesToBeUpdated: 10, instancesUpdated: 2, progress: 20});
+  });
+
+  it('Should respect deleted instances', () => {
+    const instructions = UpdateInstructionsBuilder
+      .initialState([InstanceTaskConfigBuilder.instances([{first: 0, last: 9}]).build()])
+      .desiredState(InstanceTaskConfigBuilder.instances([{first: 0, last: 4}]).build())
+      .build();
+
+    const update = UpdateDetailsBuilder
+      .update(UpdateBuilder.instructions(instructions).build())
+      .instanceEvents([
+        instanceUpdated.instanceId(7).build(),
+        instanceUpdated.instanceId(8).build()
+      ])
+      .build();
+    const stats = updateStats(update);
+    expect(stats).toEqual({totalInstancesToBeUpdated: 10, instancesUpdated: 2, progress: 20});
+  });
+
+  it('Any instances updated should show up in stats, even if rolled back', () => {
+    const instructions = UpdateInstructionsBuilder
+      .initialState([InstanceTaskConfigBuilder.instances([{first: 0, last: 9}]).build()])
+      .desiredState(InstanceTaskConfigBuilder.instances([{first: 0, last: 9}]).build())
+      .build();
+
+    const update = UpdateDetailsBuilder
+      .update(UpdateBuilder.instructions(instructions).build())
+      .instanceEvents([
+        instanceUpdated.instanceId(0).build(),
+        instanceUpdated.instanceId(2).build(),
+        InstanceUpdateEventBuilder.instanceId(2)
+          .action(JobUpdateAction.INSTANCE_UPDATE_FAILED)
+          .timestampMs(2)
+          .build()
+      ])
+      .build();
+    const stats = updateStats(update);
+    expect(stats).toEqual({totalInstancesToBeUpdated: 10, instancesUpdated: 2, progress: 20});
+  });
+});

http://git-wip-us.apache.org/repos/asf/aurora/blob/4a1fba3c/ui/src/main/sass/app.scss
----------------------------------------------------------------------
diff --git a/ui/src/main/sass/app.scss b/ui/src/main/sass/app.scss
index e301d4c..315b666 100644
--- a/ui/src/main/sass/app.scss
+++ b/ui/src/main/sass/app.scss
@@ -6,12 +6,15 @@
 
 /* Indiviudal Components */
 @import 'components/breadcrumb';
+@import 'components/instance-viz';
 @import 'components/navigation';
 @import 'components/state-machine';
 @import 'components/status';
 @import 'components/tables';
+@import 'components/update-list';
 
 /* Page Styles */
 @import 'components/home-page';
 @import 'components/instance-page';
-@import 'components/job-list-page';
\ No newline at end of file
+@import 'components/job-list-page';
+@import 'components/update-page';
\ No newline at end of file

http://git-wip-us.apache.org/repos/asf/aurora/blob/4a1fba3c/ui/src/main/sass/components/_instance-viz.scss
----------------------------------------------------------------------
diff --git a/ui/src/main/sass/components/_instance-viz.scss b/ui/src/main/sass/components/_instance-viz.scss
new file mode 100644
index 0000000..36a94a6
--- /dev/null
+++ b/ui/src/main/sass/components/_instance-viz.scss
@@ -0,0 +1,78 @@
+.instance-grid {
+  list-style-type: none;
+  margin: 0;
+  padding: 0;
+  margin-bottom: 20px;
+  overflow: hidden;
+
+  li {
+    padding: 0;
+    float: left;
+    margin-right: 2px;
+    margin-bottom: 2px;
+  }
+
+  .instance-id {
+    visibility: hidden;
+  }
+
+  &:after {
+    clear: both;
+    content: '#';
+    visibility: hidden;
+  }
+
+  .okay {
+    border: 1px solid $colors_success;
+    background-color: $colors_success_light;
+  }
+
+  .attention {
+    border: 1px solid $colors_warning;
+    background-color: $colors_warning_light;
+  }
+
+  .error {
+    border: 1px solid $colors_error;
+    background-color: $colors_error_light;
+  }
+
+  .in-progress {
+    border: 1px solid $colors_highlight;
+    background-color: $colors_highlight_light;
+  }
+
+  .removed {
+    border: 1px solid #444;
+    background-color: #999;
+  }
+
+  .instance-updated {
+    border: 1px solid $colors_success;
+    background-color: $colors_success_light;
+  }
+
+  .pending {
+    border: 1px solid $colors_success;
+  }
+
+  .instance-updating {
+    border: 1px solid $success_secondary_color;
+    background-color: $success_secondary_color;
+  }
+}
+
+.instance-grid.small li {
+  width: 3px;
+  height: 6px;
+}
+
+.instance-grid.medium li {
+  width: 6px;
+  height: 12px;
+}
+
+.instance-grid.big li {
+  width: 15px;
+  height: 25px;
+}
\ No newline at end of file

http://git-wip-us.apache.org/repos/asf/aurora/blob/4a1fba3c/ui/src/main/sass/components/_layout.scss
----------------------------------------------------------------------
diff --git a/ui/src/main/sass/components/_layout.scss b/ui/src/main/sass/components/_layout.scss
index 4ebec05..4818ab2 100644
--- a/ui/src/main/sass/components/_layout.scss
+++ b/ui/src/main/sass/components/_layout.scss
@@ -21,6 +21,16 @@
     font-size: 18px;
   }
 
+  .content-panel-subtitle {
+    padding: 15px 20px 5px 20px;
+    background-color: rgba(250, 250, 250, 1);
+    border-bottom: 1px solid $grid_color;
+    text-transform: uppercase;
+    color: #555;
+    font-size: 14px;
+    font-weight: 700;
+  }
+
   .content-panel {
     padding: 20px;
     background-color: $content_box_color;
@@ -29,6 +39,81 @@
   .content-panel + .content-panel {
     margin-top: 1px;
   }
+
+  .fluid {
+    padding: 0px;
+  }
+
+  .content-icon-title {
+    background-color: $content_box_color;
+
+    .content-icon-title-text {
+      background-color: $grid_color;
+      padding: 15px 10px;
+      margin-left: -20px;
+      display: block;
+      color: white;
+
+      font-weight: $heavy;
+      text-transform: uppercase;
+      font-size: 24px;
+
+      .glyphicon {
+        font-size: 0.8em;
+      }
+    }
+
+    &:after {
+      content: "";
+      width: 0;
+      height: 0;
+      display: block;
+      margin-left: -20px;
+      border-width: 0 20px 12px 0;
+      border-color: transparent $grid_color transparent transparent;
+      border-style: solid;
+    }
+  }
+}
+
+.success {
+  .content-icon-title-text {
+    background-color: $colors_success !important;
+  }
+
+  &:after {
+    border-color: transparent $colors_success_dark transparent transparent !important;
+  }
+}
+
+.error {
+  .content-icon-title-text {
+    background-color: $colors_error !important;
+  }
+
+  &:after {
+    border-color: transparent $colors_error_dark transparent transparent !important;
+  }
+}
+
+.highlight {
+  .content-icon-title-text {
+    background-color: $colors_highlight !important;
+  }
+
+  &:after {
+    border-color: transparent $colors_highlight_dark transparent transparent !important;
+  }
+}
+
+.attention {
+  .content-icon-title-text {
+    background-color: $colors_warning !important;
+  }
+
+  &:after {
+    border-color: transparent $colors_warning_dark transparent transparent !important;
+  }
 }
 
 .content-panel-fluid {

http://git-wip-us.apache.org/repos/asf/aurora/blob/4a1fba3c/ui/src/main/sass/components/_update-list.scss
----------------------------------------------------------------------
diff --git a/ui/src/main/sass/components/_update-list.scss b/ui/src/main/sass/components/_update-list.scss
new file mode 100644
index 0000000..83a1f5a
--- /dev/null
+++ b/ui/src/main/sass/components/_update-list.scss
@@ -0,0 +1,38 @@
+.update-list {
+  .update-list-item {
+    display: flex;
+    align-items: center;
+    padding: 10px 20px;
+    border-bottom: 1px solid $grid_color;
+
+    .img-circle {
+      margin-right: 10px;
+    }
+
+    a {
+      font-size: 16px;
+      font-weight: 600;
+    }
+
+    .update-list-item-status {
+      display: block;
+    }
+
+    &:hover {
+      background-color: #edf5fd;
+    }
+
+    .update-list-user {
+      font-weight: 600;
+    }
+
+    .update-list-status {
+      color: $secondary_font_color;
+    }
+
+    .update-list-last-updated {
+      margin-left: auto;
+      color: $secondary_font_color;
+    }
+  }
+}

http://git-wip-us.apache.org/repos/asf/aurora/blob/4a1fba3c/ui/src/main/sass/components/_update-page.scss
----------------------------------------------------------------------
diff --git a/ui/src/main/sass/components/_update-page.scss b/ui/src/main/sass/components/_update-page.scss
new file mode 100644
index 0000000..a0db0b4
--- /dev/null
+++ b/ui/src/main/sass/components/_update-page.scss
@@ -0,0 +1,151 @@
+.update-page {
+  pre {
+    border: 0;
+    background-color: $content_box_color;
+  }
+
+  .update-summary-stats {
+    display: flex;
+    justify-content: space-between;
+    align-items: baseline;
+    text-transform: uppercase;
+
+    h5 {
+      margin: 0;
+      font-size: 14px;
+    }
+
+    span {
+      font-size: 12px;
+    }
+  }
+
+  .update-settings {
+    width: 100%;
+    font-size: 14px;
+    text-transform: uppercase;
+
+    td {
+      padding: 3px;
+    }
+  }
+
+  .update-byline {
+    margin-top: -15px;
+    color: $secondary_font_color;
+
+    span + span {
+      margin: 0px 3px;
+    }
+
+    span:last-child {
+      margin: 0;
+    }
+  }
+
+  .update-time {
+    text-align: center;
+    text-transform: uppercase;
+  }
+
+  .update-time h4 {
+    font-size: 33px;
+    margin: 0;
+  }
+
+  .update-time .time-ago {
+    color: #999;
+  }
+
+  .time-divider {
+    text-align: center;
+    font-size: 70px;
+    font-weight: bold;
+    color: #ccc;
+  }
+
+  .time-display-duration {
+    text-transform: uppercase;
+    text-align: center;
+    color: #999;
+    margin-top: -20px;
+    margin-bottom: 20px;
+  }
+
+  .update-duration {
+    text-align: center;
+    color: #999;
+    text-transform: uppercase;
+    margin-bottom: 20px;
+  }
+
+  .update-time-range {
+    display: flex;
+    justify-content: space-around;
+    align-items: center;
+    margin-top: 20px;
+
+    h5 {
+      color: #CCC;
+      font-weight: 900;
+      font-size: 60px;
+      line-height: 40px;
+    }
+
+    .update-time, .update-duration {
+      font-size: 12px;
+    }
+  }
+
+  .instance-events {
+    .glyphicon-chevron-right {
+      color: $secondary_font_color;
+      font-size: 12px;
+    }
+
+    .update-instance-event-id {
+      font-weight: $heavy;
+      margin: 0 5px;
+      display: inline-block;
+    }
+
+    .update-instance-event-time {
+      float: right;
+      color: $secondary_font_color;
+      display: inline-block;
+      font-size: 14px;
+    }
+
+    .update-instance-event-status {
+      .glyphicon {
+        margin-left: 5px;
+      }
+
+      .okay {
+        color: $colors_success !important;
+      }
+
+      .attention {
+        color: $colors_warning !important;
+      }
+
+      .error {
+        color: $colors_error !important;
+      }
+
+      .in-progress {
+        color: $colors_highlight !important;
+      }
+    }
+
+    .update-instance-event-container {
+      border-bottom: 1px solid $grid_color;
+      padding: 5px 20px;
+
+      &:hover {
+        cursor: pointer;
+        cursor: hand;
+      }
+    }
+  }
+}
\ No newline at end of file

http://git-wip-us.apache.org/repos/asf/aurora/blob/4a1fba3c/ui/test-setup.js
----------------------------------------------------------------------
diff --git a/ui/test-setup.js b/ui/test-setup.js
index a403434..a86a89a 100644
--- a/ui/test-setup.js
+++ b/ui/test-setup.js
@@ -28,4 +28,39 @@ global.ScheduleStatus = {
 };
 global.ACTIVE_STATES = [9,17,6,0,13,12,2,1,16];
 
-global.TaskQuery = () => {};
\ No newline at end of file
+global.TaskQuery = () => {};
+global.JobKey = () => {};
+global.JobUpdateKey = () => {};
+global.JobUpdateQuery = () => {};
+
+global.JobUpdateStatus = {
+  'ROLLING_FORWARD' : 0,
+  'ROLLING_BACK' : 1,
+  'ROLL_FORWARD_PAUSED' : 2,
+  'ROLL_BACK_PAUSED' : 3,
+  'ROLLED_FORWARD' : 4,
+  'ROLLED_BACK' : 5,
+  'ABORTED' : 6,
+  'ERROR' : 7,
+  'FAILED' : 8,
+  'ROLL_FORWARD_AWAITING_PULSE' : 9,
+  'ROLL_BACK_AWAITING_PULSE' : 10
+};
+
+global.ACTIVE_JOB_UPDATE_STATES = [
+  JobUpdateStatus.ROLLING_FORWARD,
+  JobUpdateStatus.ROLLING_BACK,
+  JobUpdateStatus.ROLL_FORWARD_PAUSED,
+  JobUpdateStatus.ROLL_BACK_PAUSED,
+  JobUpdateStatus.ROLL_FORWARD_AWAITING_PULSE,
+  JobUpdateStatus.ROLL_BACK_AWAITING_PULSE
+];
+
+global.JobUpdateAction = {
+  'INSTANCE_UPDATED' : 1,
+  'INSTANCE_ROLLED_BACK' : 2,
+  'INSTANCE_UPDATING' : 3,
+  'INSTANCE_ROLLING_BACK' : 4,
+  'INSTANCE_UPDATE_FAILED' : 5,
+  'INSTANCE_ROLLBACK_FAILED' : 6
+};