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/03 19:59:08 UTC

aurora git commit: Implement Role and Environment pages in Preact.

Repository: aurora
Updated Branches:
  refs/heads/master 6fd6d5028 -> 4e7cdc422


Implement Role and Environment pages in Preact.

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


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

Branch: refs/heads/master
Commit: 4e7cdc422b033a89337d59541e1979283f363016
Parents: 6fd6d50
Author: David McLaughlin <da...@dmclaughlin.com>
Authored: Tue Oct 3 12:49:38 2017 -0700
Committer: David McLaughlin <da...@dmclaughlin.com>
Committed: Tue Oct 3 12:49:38 2017 -0700

----------------------------------------------------------------------
 ui/src/main/js/components/JobList.js            | 67 ++++++++++++++++
 ui/src/main/js/components/JobListItem.js        | 45 +++++++++++
 ui/src/main/js/components/Layout.js             | 16 ++++
 ui/src/main/js/components/Pagination.js         |  9 ++-
 ui/src/main/js/components/RoleQuota.js          | 78 +++++++++++++++++++
 .../js/components/__tests__/JobList-test.js     | 25 ++++++
 .../js/components/__tests__/RoleQuota-test.js   | 35 +++++++++
 ui/src/main/js/index.js                         |  9 ++-
 ui/src/main/js/pages/Jobs.js                    | 60 ++++++++++++++
 ui/src/main/js/pages/__tests__/Jobs-test.js     | 65 ++++++++++++++++
 ui/src/main/js/utils/Common.js                  |  3 +
 ui/src/main/js/utils/Job.js                     |  3 +
 ui/src/main/sass/app.scss                       |  3 +-
 ui/src/main/sass/components/_job-list-page.scss | 82 ++++++++++++++++++++
 ui/src/main/sass/components/_layout.scss        | 27 +++++++
 ui/src/main/sass/components/_tables.scss        | 22 ++++++
 ui/src/main/sass/modules/_colors.scss           | 20 +++++
 17 files changed, 562 insertions(+), 7 deletions(-)
----------------------------------------------------------------------


http://git-wip-us.apache.org/repos/asf/aurora/blob/4e7cdc42/ui/src/main/js/components/JobList.js
----------------------------------------------------------------------
diff --git a/ui/src/main/js/components/JobList.js b/ui/src/main/js/components/JobList.js
new file mode 100644
index 0000000..ff5377d
--- /dev/null
+++ b/ui/src/main/js/components/JobList.js
@@ -0,0 +1,67 @@
+import React from 'react';
+
+import Icon from 'components/Icon';
+import JobListItem from 'components/JobListItem';
+import Pagination from 'components/Pagination';
+
+import { isNully } from 'utils/Common';
+import { TASK_COUNTS } from 'utils/Job';
+
+export function JobListSortControl({ onClick }) {
+  return (<ul className='job-task-stats job-list-sort-control'>
+    <li>sort by:</li>
+    {TASK_COUNTS.map((key) => {
+      const label = key.replace('TaskCount', '');
+      return (<li key={key} onClick={(e) => onClick(key)}>
+        <span className={`img-circle ${label}-task`} /> {label}
+      </li>);
+    })}
+  </ul>);
+}
+
+export default class JobList extends React.Component {
+  constructor(props) {
+    super(props);
+    this.state = {filter: props.filter, sortBy: props.sortBy};
+  }
+
+  setFilter(e) {
+    this.setState({filter: e.target.value});
+  }
+
+  setSort(sortBy) {
+    this.setState({sortBy});
+  }
+
+  _renderRow(job) {
+    return <JobListItem job={job} key={`${job.job.key.environment}/${job.job.key.name}`} />;
+  }
+
+  render() {
+    const that = this;
+    const sortFn = this.state.sortBy ? (j) => j.stats[that.state.sortBy] : (j) => j.job.key.name;
+    const filterFn = (j) => that.state.filter ? j.job.key.name.startsWith(that.state.filter) : true;
+    return (<div className='job-list'>
+      <div className='table-input-wrapper'>
+        <Icon name='search' />
+        <input
+          autoFocus
+          onChange={(e) => this.setFilter(e)}
+          placeholder='Search jobs...'
+          type='text' />
+      </div>
+      <JobListSortControl onClick={(key) => this.setSort(key)} />
+      <table className='psuedo-table'>
+        <Pagination
+          data={this.props.jobs}
+          filter={filterFn}
+          isTable
+          numberPerPage={25}
+          renderer={this._renderRow}
+          // Always sort task count sorts in descending fashion (for UX reasons)
+          reverseSort={!isNully(this.state.sortBy)}
+          sortBy={sortFn} />
+      </table>
+    </div>);
+  }
+}

http://git-wip-us.apache.org/repos/asf/aurora/blob/4e7cdc42/ui/src/main/js/components/JobListItem.js
----------------------------------------------------------------------
diff --git a/ui/src/main/js/components/JobListItem.js b/ui/src/main/js/components/JobListItem.js
new file mode 100644
index 0000000..5a461dd
--- /dev/null
+++ b/ui/src/main/js/components/JobListItem.js
@@ -0,0 +1,45 @@
+import React from 'react';
+import { Link } from 'react-router-dom';
+
+import Icon from 'components/Icon';
+
+import { TASK_COUNTS } from 'utils/Job';
+
+export function JobTaskStats({ stats }) {
+  const taskStats = [];
+  TASK_COUNTS.forEach((k) => {
+    if (stats[k] > 0) {
+      const label = k.replace('TaskCount', '');
+      taskStats.push(<li key={k}><span className={`img-circle ${label}-task`} /> {stats[k]} </li>);
+    }
+  });
+  return <ul className='job-task-stats'>{taskStats}</ul>;
+}
+
+export default function JobListItem(props) {
+  const {job: {job: { cronSchedule, key: {role, name, environment}, taskConfig }, stats}} = props;
+
+  const envLink = (props.env) ? '' : (<span className='job-env'>
+    <Link to={`/beta/scheduler/${role}/${environment}`}>{environment}</Link>
+  </span>);
+
+  return (<tr key={`${environment}/${name}`}>
+    <td className='job-list-type' column='type'>
+      <span className='job-tier'>
+        {taskConfig.isService ? 'service' : (cronSchedule) ? 'cron' : 'adhoc'}
+      </span>
+    </td>
+    <td className='job-list-name' column='name' value={name}>
+      <h4>
+        {envLink}
+        <Link to={`/beta/scheduler/${role}/${environment}/${name}`}>
+          {name}
+          {taskConfig.production ? <Icon name='star' /> : ''}
+        </Link>
+      </h4>
+    </td>
+    <td className='job-list-stats' column='stats'>
+      <JobTaskStats stats={stats} />
+    </td>
+  </tr>);
+}

http://git-wip-us.apache.org/repos/asf/aurora/blob/4e7cdc42/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
new file mode 100644
index 0000000..4ca54e3
--- /dev/null
+++ b/ui/src/main/js/components/Layout.js
@@ -0,0 +1,16 @@
+import React from 'react';
+
+function ContentPanel({ children }) {
+  return <div className='content-panel'>{children}</div>;
+}
+
+export function StandardPanelTitle({ title }) {
+  return <div className='content-panel-title'>{title}</div>;
+}
+
+export default function PanelGroup({ children, title }) {
+  return (<div className='content-panel-group'>
+    {title}
+    {React.Children.map(children, (p) => <ContentPanel>{p}</ContentPanel>)}
+  </div>);
+}

http://git-wip-us.apache.org/repos/asf/aurora/blob/4e7cdc42/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 dec89be..7bf2c04 100644
--- a/ui/src/main/js/components/Pagination.js
+++ b/ui/src/main/js/components/Pagination.js
@@ -56,6 +56,11 @@ export default class Pagination extends React.Component {
     const { reverseSort, sortBy } = this.props;
     const gte = reverseSort ? -1 : 1;
     const lte = reverseSort ? 1 : -1;
+    if (typeof sortBy === 'function') {
+      return data.sort((a, b) => {
+        return (sortBy(a) > sortBy(b)) ? gte : lte;
+      });
+    }
     return data.sort((a, b) => {
       return (a[sortBy] > b[sortBy]) ? gte : lte;
     });
@@ -88,8 +93,8 @@ export default class Pagination extends React.Component {
       numPages={Math.ceil(filtered.length / numberPerPage)}
       onClick={(page) => that.changePage(page)} />;
 
-    // React/JSX statements must resolve to a single node, so we need to wrap the page in a parent.
-    // We need the caller to be able to signify they are paging through a table element.
+    // We need the caller to be able to signify they are paging through a table element so
+    // we know to wrap the pagination links in a tr.
     if (isTable) {
       return (<tbody>
         {elements}

http://git-wip-us.apache.org/repos/asf/aurora/blob/4e7cdc42/ui/src/main/js/components/RoleQuota.js
----------------------------------------------------------------------
diff --git a/ui/src/main/js/components/RoleQuota.js b/ui/src/main/js/components/RoleQuota.js
new file mode 100644
index 0000000..36a69b4
--- /dev/null
+++ b/ui/src/main/js/components/RoleQuota.js
@@ -0,0 +1,78 @@
+import React from 'react';
+
+import { isNully } from 'utils/Common';
+
+const QUOTA_TYPE_ORDER = [
+  'quota',
+  'prodSharedConsumption',
+  'prodDedicatedConsumption',
+  'nonProdSharedConsumption',
+  'nonProdDedicatedConsumption'
+];
+
+// @VisibleForTesting
+export const QUOTA_TYPE_MAP = {
+  'quota': 'Quota',
+  'prodSharedConsumption': 'Quota Used',
+  'nonProdSharedConsumption': 'Non-Production',
+  'prodDedicatedConsumption': 'Production Dedicated',
+  'nonProdDedicatedConsumption': 'Non-Production Dedicated'
+};
+
+const UNITS = ['MiB', 'GiB', 'TiB', 'PiB', 'EiB'];
+
+function formatMb(sizeInMb) {
+  const unitIdx = (sizeInMb > 0) ? Math.floor(Math.log(sizeInMb) / Math.log(1024)) : 0;
+  return (sizeInMb / Math.pow(1024, unitIdx)).toFixed(2) + '' + UNITS[unitIdx];
+}
+
+const CONVERSIONS = {
+  diskMb: formatMb,
+  ramMb: formatMb
+};
+
+function format(resource) {
+  const resourceKey = Object.keys(resource).find((key) => !isNully(resource[key]));
+  return (CONVERSIONS[resourceKey])
+    ? CONVERSIONS[resourceKey](resource[resourceKey])
+    : resource[resourceKey];
+}
+
+function getResource(resources, key) {
+  return format(resources.find((r) => !isNully(r[key])));
+}
+
+function findResource(resource) {
+  const resourceKey = Object.keys(resource).find((key) => !isNully(resource[key]));
+  return resource[resourceKey];
+}
+
+const totalResources = (resources) => resources.map(findResource).reduce((acc, val) => acc + val);
+
+export default function RoleQuota({ quota }) {
+  // Only show quota types with non-zero values.
+  const quotas = QUOTA_TYPE_ORDER.filter((t) => totalResources(quota[t].resources) > 0);
+
+  return (<div className='role-quota'>
+    <table className='aurora-table'>
+      <thead>
+        <tr>
+          <th>&nbsp;</th>
+          <th>cpus</th>
+          <th>ram</th>
+          <th>disk</th>
+        </tr>
+      </thead>
+      <tbody>
+        {quotas.map((t) => (
+          <tr key={t}>
+            <td>{QUOTA_TYPE_MAP[t]}</td>
+            <td>{getResource(quota[t].resources, 'numCpus')}</td>
+            <td>{getResource(quota[t].resources, 'ramMb')}</td>
+            <td>{getResource(quota[t].resources, 'diskMb')}</td>
+          </tr>
+        ))}
+      </tbody>
+    </table>
+  </div>);
+}

http://git-wip-us.apache.org/repos/asf/aurora/blob/4e7cdc42/ui/src/main/js/components/__tests__/JobList-test.js
----------------------------------------------------------------------
diff --git a/ui/src/main/js/components/__tests__/JobList-test.js b/ui/src/main/js/components/__tests__/JobList-test.js
new file mode 100644
index 0000000..f545c24
--- /dev/null
+++ b/ui/src/main/js/components/__tests__/JobList-test.js
@@ -0,0 +1,25 @@
+import React from 'react';
+import { shallow } from 'enzyme';
+
+import JobList from '../JobList';
+import Pagination from '../Pagination';
+
+const jobs = []; // only need referential equality for tests
+
+describe('JobList', () => {
+  it('Should delegate all the heavy lifting to Pagination', () => {
+    const el = shallow(<JobList jobs={jobs} />);
+    expect(el.find(Pagination).length).toBe(1);
+    expect(el.containsAllMatchingElements([
+      <Pagination data={jobs} reverseSort={false} />
+    ])).toBe(true);
+  });
+
+  it('Should reverse sort whenever a task sort is supplied', () => {
+    const el = shallow(<JobList jobs={jobs} sortBy='test' />);
+    expect(el.find(Pagination).length).toBe(1);
+    expect(el.containsAllMatchingElements([
+      <Pagination data={jobs} reverseSort />
+    ])).toBe(true);
+  });
+});

http://git-wip-us.apache.org/repos/asf/aurora/blob/4e7cdc42/ui/src/main/js/components/__tests__/RoleQuota-test.js
----------------------------------------------------------------------
diff --git a/ui/src/main/js/components/__tests__/RoleQuota-test.js b/ui/src/main/js/components/__tests__/RoleQuota-test.js
new file mode 100644
index 0000000..57f332d
--- /dev/null
+++ b/ui/src/main/js/components/__tests__/RoleQuota-test.js
@@ -0,0 +1,35 @@
+import React from 'react';
+import { shallow } from 'enzyme';
+
+import RoleQuota, { QUOTA_TYPE_MAP } from '../RoleQuota';
+
+function createResources(cpus, mem, disk) {
+  return [{
+    'numCpus': cpus
+  }, {
+    'ramMb': mem
+  }, {
+    'diskMb': disk
+  }];
+}
+
+function initQuota() {
+  const quota = {};
+  Object.keys(QUOTA_TYPE_MAP).forEach((key) => {
+    quota[key] = {resources: createResources(0, 0, 0)};
+  });
+  return quota;
+}
+
+describe('RoleQuota', () => {
+  it('Should show all resources used', () => {
+    Object.keys(QUOTA_TYPE_MAP).forEach((key) => {
+      const quota = initQuota();
+      quota[key] = {resources: createResources(10, 1024, 1024)};
+      const el = shallow(<RoleQuota quota={quota} />);
+      expect(el.contains(<tr key={key}>
+        <td>{QUOTA_TYPE_MAP[key]}</td><td>{10}</td><td>{'1.00GiB'}</td><td>{'1.00GiB'}</td>
+      </tr>)).toBe(true);
+    });
+  });
+});

http://git-wip-us.apache.org/repos/asf/aurora/blob/4e7cdc42/ui/src/main/js/index.js
----------------------------------------------------------------------
diff --git a/ui/src/main/js/index.js b/ui/src/main/js/index.js
index 717aaa0..4a879e6 100644
--- a/ui/src/main/js/index.js
+++ b/ui/src/main/js/index.js
@@ -5,18 +5,19 @@ import { BrowserRouter as Router, Route } from 'react-router-dom';
 import SchedulerClient from 'client/scheduler-client';
 import Navigation from 'components/Navigation';
 import Home from 'pages/Home';
+import Jobs from 'pages/Jobs';
 
 import styles from '../sass/app.scss'; // eslint-disable-line no-unused-vars
 
-const apiEnabledComponent = (Page) => (props) => <Page api={SchedulerClient} {...props} />;
+const injectApi = (Page) => (props) => <Page api={SchedulerClient} {...props} />;
 
 const SchedulerUI = () => (
   <Router>
     <div>
       <Navigation />
-      <Route component={apiEnabledComponent(Home)} exact path='/beta/scheduler' />
-      <Route component={Home} exact path='/beta/scheduler/:role' />
-      <Route component={Home} exact path='/beta/scheduler/:role/:environment' />
+      <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={Home} exact path='/beta/scheduler/:role/:environment/:name/:instance' />
       <Route component={Home} exact path='/beta/scheduler/:role/:environment/:name/update/:uid' />

http://git-wip-us.apache.org/repos/asf/aurora/blob/4e7cdc42/ui/src/main/js/pages/Jobs.js
----------------------------------------------------------------------
diff --git a/ui/src/main/js/pages/Jobs.js b/ui/src/main/js/pages/Jobs.js
new file mode 100644
index 0000000..d3bef8c
--- /dev/null
+++ b/ui/src/main/js/pages/Jobs.js
@@ -0,0 +1,60 @@
+import React from 'react';
+
+import Breadcrumb from 'components/Breadcrumb';
+import JobList from 'components/JobList';
+import PanelGroup, { StandardPanelTitle } from 'components/Layout';
+import Loading from 'components/Loading';
+import RoleQuota from 'components/RoleQuota';
+
+import { isNully } from 'utils/Common';
+
+export default class Jobs extends React.Component {
+  constructor(props) {
+    super(props);
+    this.state = {cluster: '', jobs: [], loading: isNully(props.jobs)};
+  }
+
+  componentWillMount() {
+    const that = this;
+    this.props.api.getJobSummary(this.props.match.params.role, (response) => {
+      const jobs = (that.props.match.params.environment)
+        ? response.result.jobSummaryResult.summaries.filter(
+          (j) => j.job.key.environment === that.props.match.params.environment)
+        : response.result.jobSummaryResult.summaries;
+      that.setState({ cluster: response.serverInfo.clusterName, loading: false, jobs });
+    });
+
+    this.props.api.getQuota(this.props.match.params.role, (response) => {
+      that.setState({
+        cluster: response.serverInfo.clusterName,
+        loading: false,
+        quota: response.result.getQuotaResult
+      });
+    });
+  }
+
+  render() {
+    return this.state.loading ? <Loading /> : (<div>
+      <Breadcrumb
+        cluster={this.state.cluster}
+        env={this.props.match.params.environment}
+        role={this.props.match.params.role} />
+      <div className='container'>
+        <div className='row'>
+          <div className='col-md-12'>
+            <PanelGroup title={<StandardPanelTitle title='Resources' />}>
+              <RoleQuota quota={this.state.quota} />
+            </PanelGroup>
+          </div>
+        </div>
+        <div className='row'>
+          <div className='col-md-12'>
+            <PanelGroup title={<StandardPanelTitle title='Jobs' />}>
+              <JobList env={this.props.match.params.environment} jobs={this.state.jobs} />
+            </PanelGroup>
+          </div>
+        </div>
+      </div>
+    </div>);
+  }
+}

http://git-wip-us.apache.org/repos/asf/aurora/blob/4e7cdc42/ui/src/main/js/pages/__tests__/Jobs-test.js
----------------------------------------------------------------------
diff --git a/ui/src/main/js/pages/__tests__/Jobs-test.js b/ui/src/main/js/pages/__tests__/Jobs-test.js
new file mode 100644
index 0000000..4fc6778
--- /dev/null
+++ b/ui/src/main/js/pages/__tests__/Jobs-test.js
@@ -0,0 +1,65 @@
+import React from 'react';
+import { shallow } from 'enzyme';
+
+import Jobs from '../Jobs';
+import Breadcrumb from 'components/Breadcrumb';
+import JobList from 'components/JobList';
+import Loading from 'components/Loading';
+import RoleQuota from 'components/RoleQuota';
+
+const TEST_CLUSTER = 'test-cluster';
+const TEST_ENV = 'test-env';
+const TEST_ROLE = 'test-role';
+
+function createMockApi(jobs, quota) {
+  const api = {};
+  api.getJobSummary = (role, handler) => handler({
+    result: {
+      jobSummaryResult: {
+        summaries: jobs
+      }
+    },
+    serverInfo: {
+      clusterName: TEST_CLUSTER
+    }
+  });
+
+  api.getQuota = (role, handler) => handler({
+    result: {
+      getQuotaResult: quota
+    },
+    serverInfo: {
+      clusterName: TEST_CLUSTER
+    }
+  });
+
+  return api;
+}
+
+const jobs = [{job: {key: {environment: TEST_ENV}}}, {job: {key: {environment: 'wrong-env'}}}];
+const quota = {}; // No keys are accessed, so just do reference equality later.
+
+describe('Jobs', () => {
+  it('Should render Loading before data is fetched', () => {
+    expect(shallow(<Jobs
+      api={{getJobSummary: () => {}, getQuota: () => {}}}
+      match={{params: {role: TEST_ROLE}}} />).equals(<Loading />)).toBe(true);
+  });
+
+  it('Should render page elements when jobs are fetched', () => {
+    const el = shallow(
+      <Jobs api={createMockApi(jobs, quota)} match={{params: {role: TEST_ROLE}}} />);
+    expect(el.contains(
+      <Breadcrumb cluster={TEST_CLUSTER} env={undefined} role={TEST_ROLE} />)).toBe(true);
+    expect(el.find(JobList).length).toBe(1);
+    expect(el.find(RoleQuota).length).toBe(1);
+  });
+
+  it('Should pass through environment path parameter and filter jobs', () => {
+    const home = shallow(<Jobs
+      api={createMockApi(jobs)}
+      match={{params: {environment: TEST_ENV, role: TEST_ROLE}}} />);
+    expect(home.contains(
+      <Breadcrumb cluster={TEST_CLUSTER} env={TEST_ENV} role={TEST_ROLE} />)).toBe(true);
+  });
+});

http://git-wip-us.apache.org/repos/asf/aurora/blob/4e7cdc42/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
new file mode 100644
index 0000000..2e12e3c
--- /dev/null
+++ b/ui/src/main/js/utils/Common.js
@@ -0,0 +1,3 @@
+export function isNully(value) {
+  return typeof value === 'undefined' || value === null;
+}

http://git-wip-us.apache.org/repos/asf/aurora/blob/4e7cdc42/ui/src/main/js/utils/Job.js
----------------------------------------------------------------------
diff --git a/ui/src/main/js/utils/Job.js b/ui/src/main/js/utils/Job.js
new file mode 100644
index 0000000..a0bd5c8
--- /dev/null
+++ b/ui/src/main/js/utils/Job.js
@@ -0,0 +1,3 @@
+export const TASK_COUNTS = [
+  'pendingTaskCount', 'activeTaskCount', 'finishedTaskCount', 'failedTaskCount'
+];

http://git-wip-us.apache.org/repos/asf/aurora/blob/4e7cdc42/ui/src/main/sass/app.scss
----------------------------------------------------------------------
diff --git a/ui/src/main/sass/app.scss b/ui/src/main/sass/app.scss
index 0b3967d..d9673d0 100644
--- a/ui/src/main/sass/app.scss
+++ b/ui/src/main/sass/app.scss
@@ -10,4 +10,5 @@
 @import 'components/tables';
 
 /* Page Styles */
-@import 'components/home-page';
\ No newline at end of file
+@import 'components/home-page';
+@import 'components/job-list-page';
\ No newline at end of file

http://git-wip-us.apache.org/repos/asf/aurora/blob/4e7cdc42/ui/src/main/sass/components/_job-list-page.scss
----------------------------------------------------------------------
diff --git a/ui/src/main/sass/components/_job-list-page.scss b/ui/src/main/sass/components/_job-list-page.scss
new file mode 100644
index 0000000..d31344d
--- /dev/null
+++ b/ui/src/main/sass/components/_job-list-page.scss
@@ -0,0 +1,82 @@
+.job-task-stats {
+  display: block;
+  padding: 0;
+  margin: 0 10px;
+  white-space: nowrap;
+
+  li {
+    display: inline-block;
+    margin-right: 12px;
+    font-size: 16px;
+  }
+
+  .img-circle {
+    width: 10px;
+    height: 10px;
+    background-color: #CCC;
+    display: inline-block;
+    border-radius: 50%;
+  }
+}
+
+.job-list-sort-control {
+  text-align: right;
+  margin-bottom: 10px;
+  li {
+    font-size: 14px;
+  }
+}
+
+td.job-list-name {
+  width: 99%;
+
+  .glyphicon {
+    font-size: 12px;
+    margin-left: 3px;
+  }
+}
+
+td.job-list-stats {
+  text-align: right;
+}
+
+td.job-list-type {
+  text-align: center;
+}
+
+.pending-task {
+  background-color: $colors_warning !important;
+}
+
+.active-task {
+  background-color: $colors_success !important;
+}
+
+.failed-task {
+  background-color: $colors_error !important;
+}
+
+.job-env {
+  display: inline-block;
+  font-size: 11px;
+  text-transform: uppercase;
+  overflow: hidden;
+  margin-right: 20px;
+}
+
+.job-tier {
+  display: inline-block;
+  font-size: 11px;
+  text-transform: uppercase;
+  color: #777;
+  padding: 2px;
+}
+
+.job-type {
+  font-size: 11px;
+  text-transform: uppercase;
+  display: inline-block;
+  width: 50px;
+  text-align: center;
+  color: $secondary_font_color;
+}
\ No newline at end of file

http://git-wip-us.apache.org/repos/asf/aurora/blob/4e7cdc42/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 b8a83b5..1d0553b 100644
--- a/ui/src/main/sass/components/_layout.scss
+++ b/ui/src/main/sass/components/_layout.scss
@@ -2,4 +2,31 @@
   background-color: $content_box_color;
   margin: 10px 0;
   padding: 20px;
+}
+
+.content-panel-group {
+  margin: 10px 0;
+
+  .content-panel:last-child {
+    border-radius: 0px 0px 5px 5px;
+  }
+
+  .content-panel-title {
+    padding: 15px 20px;
+    background-color: $content_box_color;
+    border-bottom: 1px solid #d7e2ec;
+    color: #555;
+    border-radius: 5px 5px 0px 0px;
+    font-weight: 700;
+    font-size: 18px;
+  }
+
+  .content-panel {
+    padding: 20px;
+    background-color: $content_box_color;
+  }
+
+  .content-panel + .content-panel {
+    margin-top: 1px;
+  }
 }
\ No newline at end of file

http://git-wip-us.apache.org/repos/asf/aurora/blob/4e7cdc42/ui/src/main/sass/components/_tables.scss
----------------------------------------------------------------------
diff --git a/ui/src/main/sass/components/_tables.scss b/ui/src/main/sass/components/_tables.scss
index 2ea60bc..3fc4b0a 100644
--- a/ui/src/main/sass/components/_tables.scss
+++ b/ui/src/main/sass/components/_tables.scss
@@ -57,6 +57,28 @@
   }
 }
 
+.psuedo-table {
+  width: 100%;
+  font-size: 16px;
+  border-top: 1px solid $grid_color;
+
+  td {
+    padding: 5px;
+  }
+
+  tr:hover {
+    background: #edf5fd;
+  }
+
+  tr {
+    border-bottom: 1px solid $grid_color;
+  }
+
+  a {
+    font-weight: 600;
+  }
+}
+
 .table-input-wrapper {
   border-radius: 4px;
   padding: 5px;

http://git-wip-us.apache.org/repos/asf/aurora/blob/4e7cdc42/ui/src/main/sass/modules/_colors.scss
----------------------------------------------------------------------
diff --git a/ui/src/main/sass/modules/_colors.scss b/ui/src/main/sass/modules/_colors.scss
index 147cff6..a75bbe9 100644
--- a/ui/src/main/sass/modules/_colors.scss
+++ b/ui/src/main/sass/modules/_colors.scss
@@ -13,3 +13,23 @@ $success_secondary_color: #afe8b8;
 
 $error_color: #d63c39;
 $error_secondary_color: rgb(230, 101, 98);
+
+// Use to signify something is in a good state.
+$colors_success_light: #afe8b8;
+$colors_success: #74C080;
+$colors_success_dark: #628a68;
+
+// Use to attract user's attention to negative behavior.
+$colors_error_light: rgb(230, 101, 98);
+$colors_error: #d63c39;
+$colors_error_dark: #882422;
+
+// Use to highlight something that *may* need attention.
+$colors_warning_light: #f5d96c;
+$colors_warning: #f3c200;
+$colors_warning_dark: #d4a900;
+
+// Use to highlight something important, but that is currently operating as expected.
+$colors_highlight_light: #8ac5f9;
+$colors_highlight: #5FA2DD;
+$colors_highlight_dark: #3b71a0;
\ No newline at end of file