You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@aurora.apache.org by dm...@apache.org on 2017/10/12 21:31:25 UTC
aurora git commit: Implement Job page in React
Repository: aurora
Updated Branches:
refs/heads/master 519e3df73 -> 2aee90d0e
Implement Job page in React
Reviewed at https://reviews.apache.org/r/62908/
Project: http://git-wip-us.apache.org/repos/asf/aurora/repo
Commit: http://git-wip-us.apache.org/repos/asf/aurora/commit/2aee90d0
Tree: http://git-wip-us.apache.org/repos/asf/aurora/tree/2aee90d0
Diff: http://git-wip-us.apache.org/repos/asf/aurora/diff/2aee90d0
Branch: refs/heads/master
Commit: 2aee90d0e31a43c7d2fd866abd2e14f811d4bc3c
Parents: 519e3df
Author: David McLaughlin <da...@dmclaughlin.com>
Authored: Thu Oct 12 14:08:39 2017 -0700
Committer: David McLaughlin <da...@dmclaughlin.com>
Committed: Thu Oct 12 14:08:39 2017 -0700
----------------------------------------------------------------------
ui/package.json | 1 +
ui/src/main/js/components/Breadcrumb.js | 12 +-
ui/src/main/js/components/ConfigDiff.js | 68 ++++++++
ui/src/main/js/components/JobConfig.js | 21 +++
ui/src/main/js/components/Pagination.js | 2 +-
ui/src/main/js/components/Tabs.js | 38 +++++
ui/src/main/js/components/TaskConfigSummary.js | 58 +++++++
ui/src/main/js/components/TaskList.js | 78 +++++++++
ui/src/main/js/components/TaskStateMachine.js | 10 ++
ui/src/main/js/components/UpdateList.js | 17 +-
ui/src/main/js/components/UpdatePreview.js | 30 ++++
.../js/components/__tests__/Breadcrumb-test.js | 8 +-
.../js/components/__tests__/ConfigDiff-test.js | 75 +++++++++
.../js/components/__tests__/JobConfig-test.js | 27 ++++
.../main/js/components/__tests__/Tabs-test.js | 39 +++++
.../js/components/__tests__/TaskList-test.js | 22 +++
ui/src/main/js/index.js | 3 +-
ui/src/main/js/pages/Job.js | 146 +++++++++++++++++
ui/src/main/js/pages/__tests__/Job-test.js | 101 ++++++++++++
ui/src/main/js/test-utils/TaskBuilders.js | 7 +
ui/src/main/js/utils/Task.js | 23 +++
ui/src/main/sass/app.scss | 2 +
ui/src/main/sass/components/_job-page.scss | 158 +++++++++++++++++++
ui/src/main/sass/components/_task-list.scss | 91 +++++++++++
24 files changed, 1023 insertions(+), 14 deletions(-)
----------------------------------------------------------------------
http://git-wip-us.apache.org/repos/asf/aurora/blob/2aee90d0/ui/package.json
----------------------------------------------------------------------
diff --git a/ui/package.json b/ui/package.json
index f4532df..cde8d10 100644
--- a/ui/package.json
+++ b/ui/package.json
@@ -4,6 +4,7 @@
"description": "UI project for Apache Aurora",
"main": "index.js",
"dependencies": {
+ "diff": "^3.4.0",
"es6-shim": "^0.35.3",
"moment": "^2.18.1",
"react": "^16.0.0",
http://git-wip-us.apache.org/repos/asf/aurora/blob/2aee90d0/ui/src/main/js/components/Breadcrumb.js
----------------------------------------------------------------------
diff --git a/ui/src/main/js/components/Breadcrumb.js b/ui/src/main/js/components/Breadcrumb.js
index 76c6270..4cd7506 100644
--- a/ui/src/main/js/components/Breadcrumb.js
+++ b/ui/src/main/js/components/Breadcrumb.js
@@ -6,28 +6,28 @@ function url(...args) {
}
export default function Breadcrumb({ cluster, role, env, name, instance, update }) {
- const crumbs = [<Link key='cluster' to='/scheduler'>{cluster}</Link>];
+ const crumbs = [<Link key='cluster' to='/beta/scheduler'>{cluster}</Link>];
if (role) {
crumbs.push(<span key='role-divider'>/</span>);
- crumbs.push(<Link key='role' to={`/scheduler/${url(role)}`}>{role}</Link>);
+ crumbs.push(<Link key='role' to={`/beta/scheduler/${url(role)}`}>{role}</Link>);
}
if (env) {
crumbs.push(<span key='env-divider'>/</span>);
- crumbs.push(<Link key='env' to={`/scheduler/${url(role, env)}`}>{env}</Link>);
+ crumbs.push(<Link key='env' to={`/beta/scheduler/${url(role, env)}`}>{env}</Link>);
}
if (name) {
crumbs.push(<span key='name-divider'>/</span>);
- crumbs.push(<Link key='name' to={`/scheduler/${url(role, env, name)}`}>{name}</Link>);
+ crumbs.push(<Link key='name' to={`/beta/scheduler/${url(role, env, name)}`}>{name}</Link>);
}
if (instance) {
crumbs.push(<span key='instance-divider'>/</span>);
- crumbs.push(<Link key='instance' to={`/scheduler/${url(role, env, name, instance)}`}>
+ crumbs.push(<Link key='instance' to={`/beta/scheduler/${url(role, env, name, instance)}`}>
{instance}
</Link>);
}
if (update) {
crumbs.push(<span key='update-divider'>/</span>);
- crumbs.push(<Link key='update' to={`/scheduler/${url(role, env, name, 'update', update)}`}>
+ crumbs.push(<Link key='update' to={`/beta/scheduler/${url(role, env, name, 'update', update)}`}>
{update}
</Link>);
}
http://git-wip-us.apache.org/repos/asf/aurora/blob/2aee90d0/ui/src/main/js/components/ConfigDiff.js
----------------------------------------------------------------------
diff --git a/ui/src/main/js/components/ConfigDiff.js b/ui/src/main/js/components/ConfigDiff.js
new file mode 100644
index 0000000..9627751
--- /dev/null
+++ b/ui/src/main/js/components/ConfigDiff.js
@@ -0,0 +1,68 @@
+import React from 'react';
+import { diffJson } from 'diff';
+
+import { instanceRangeToString } from 'utils/Task';
+
+export default class ConfigDiff extends React.Component {
+ constructor(props) {
+ super(props);
+ this.state = {
+ leftGroupIdx: 0,
+ rightGroupIdx: 1
+ };
+ }
+
+ getPicker(key) {
+ const that = this;
+ const group = this.props.groups[this.state[key]];
+ if (this.props.groups.length === 2) {
+ return (<span>
+ Instances {instanceRangeToString(group.instances)}
+ </span>);
+ } else {
+ const otherOptions = this.props.groups
+ .map((g, i) => i)
+ .filter((i) => i !== that.state.leftGroupIdx && i !== that.state.rightGroupIdx);
+ return (<span>
+ Instances <select onChange={(e) => this.setState({[key]: parseInt(e.target.value, 10)})}>
+ <option key='current'>{instanceRangeToString(group.instances)}</option>
+ {otherOptions.map((i) => (<option key={i} value={i}>
+ {instanceRangeToString(this.props.groups[i].instances)}
+ </option>))}
+ </select>
+ </span>);
+ }
+ }
+
+ diffNavigation() {
+ if (this.props.groups.length < 2) {
+ return <div>No configuration.</div>;
+ } else {
+ return (<div className='diff-picker'>
+ Config Diff for <span className='diff-before'>
+ {this.getPicker('leftGroupIdx')}
+ </span> and <span className='diff-after'>
+ {this.getPicker('rightGroupIdx')}
+ </span>
+ </div>);
+ }
+ }
+
+ render() {
+ if (this.props.groups.length < 2) {
+ return <div />;
+ }
+ const result = diffJson(
+ this.props.groups[this.state.leftGroupIdx].config,
+ this.props.groups[this.state.rightGroupIdx].config);
+ return (<div className='task-diff'>
+ {this.diffNavigation()}
+ <div className='diff-view'>
+ {result.map((r, i) => (
+ <span className={r.added ? 'added' : r.removed ? 'removed' : 'same'} key={i}>
+ {r.value}
+ </span>))}
+ </div>
+ </div>);
+ }
+}
http://git-wip-us.apache.org/repos/asf/aurora/blob/2aee90d0/ui/src/main/js/components/JobConfig.js
----------------------------------------------------------------------
diff --git a/ui/src/main/js/components/JobConfig.js b/ui/src/main/js/components/JobConfig.js
new file mode 100644
index 0000000..275f46a
--- /dev/null
+++ b/ui/src/main/js/components/JobConfig.js
@@ -0,0 +1,21 @@
+import React from 'react';
+
+import ConfigDiff from 'components/ConfigDiff';
+import Loading from 'components/Loading';
+import TaskConfigSummary from 'components/TaskConfigSummary';
+
+import { isNully, sort } from 'utils/Common';
+
+export default function JobConfig({ groups }) {
+ if (isNully(groups)) {
+ return <Loading />;
+ }
+
+ const sorted = sort(groups, (g) => g.instances[0].first);
+ return (<div className='job-configuration'>
+ <div className='job-configuration-summaries'>
+ {sorted.map((group, i) => <TaskConfigSummary key={i} {...group} />)}
+ </div>
+ <ConfigDiff groups={sorted} />
+ </div>);
+}
http://git-wip-us.apache.org/repos/asf/aurora/blob/2aee90d0/ui/src/main/js/components/Pagination.js
----------------------------------------------------------------------
diff --git a/ui/src/main/js/components/Pagination.js b/ui/src/main/js/components/Pagination.js
index 0aac09d..6a8b73e 100644
--- a/ui/src/main/js/components/Pagination.js
+++ b/ui/src/main/js/components/Pagination.js
@@ -104,7 +104,7 @@ export default class Pagination extends React.Component {
if (isTable) {
return (<tbody>
{elements}
- <tr className='pagination-row'><td colSpan='100%'>{pagination}</td></tr>
+ {pagination ? <tr className='pagination-row'><td colSpan='100%'>{pagination}</td></tr> : ''}
</tbody>);
}
return <div>{elements}{pagination}</div>;
http://git-wip-us.apache.org/repos/asf/aurora/blob/2aee90d0/ui/src/main/js/components/Tabs.js
----------------------------------------------------------------------
diff --git a/ui/src/main/js/components/Tabs.js b/ui/src/main/js/components/Tabs.js
new file mode 100644
index 0000000..43b1950
--- /dev/null
+++ b/ui/src/main/js/components/Tabs.js
@@ -0,0 +1,38 @@
+import React from 'react';
+
+import Icon from 'components/Icon';
+
+import { addClass } from 'utils/Common';
+
+export default class Tabs extends React.Component {
+ constructor(props) {
+ super(props);
+ this.state = {
+ active: props.activeTab || props.tabs[0].name
+ };
+ }
+
+ select(name) {
+ this.setState({active: name});
+ }
+
+ render() {
+ const that = this;
+ const isActive = (t) => t.name === that.state.active;
+ return (<div className={addClass('tabs', this.props.className)}>
+ <ul className='tab-navigation'>
+ {this.props.tabs.map((t) => (
+ <li
+ className={isActive(t) ? 'active' : ''}
+ key={t.name}
+ onClick={(e) => this.select(t.name)}>
+ {t.icon ? <Icon name={t.icon} /> : ''}
+ {t.name}
+ </li>))}
+ </ul>
+ <div className='active-tab'>
+ {this.props.tabs.find(isActive).content}
+ </div>
+ </div>);
+ }
+}
http://git-wip-us.apache.org/repos/asf/aurora/blob/2aee90d0/ui/src/main/js/components/TaskConfigSummary.js
----------------------------------------------------------------------
diff --git a/ui/src/main/js/components/TaskConfigSummary.js b/ui/src/main/js/components/TaskConfigSummary.js
new file mode 100644
index 0000000..69b1a40
--- /dev/null
+++ b/ui/src/main/js/components/TaskConfigSummary.js
@@ -0,0 +1,58 @@
+import React from 'react';
+
+import { constraintToString, getResource, getResources, instanceRangeToString } from 'utils/Task';
+
+export default function TaskConfigSummary({ config, instances }) {
+ return (<table className='table table-bordered task-config-summary'>
+ <tbody>
+ <tr>
+ <th colSpan='100%'>
+ Configuration for instance {instanceRangeToString(instances)}
+ </th>
+ </tr>
+ <tr>
+ <th rowSpan='4'>Resources</th>
+ <td>cpus</td>
+ <td>{getResource(config.resources, 'numCpus').numCpus}</td>
+ </tr>
+ <tr>
+ <td>ram</td>
+ <td>{getResource(config.resources, 'ramMb').ramMb}</td>
+ </tr>
+ <tr>
+ <td>disk</td>
+ <td>{getResource(config.resources, 'diskMb').diskMb}</td>
+ </tr>
+ <tr>
+ <td>ports</td>
+ <td>{getResources(config.resources, 'namedPort').map((r) => r.namedPort).join(', ')}</td>
+ </tr>
+ <tr>
+ <th>Constraints</th>
+ <td colSpan='2'>
+ {config.constraints.map((t) => (<span key={t.name}>
+ {t.name}: {constraintToString(t.constraint)}
+ </span>))}
+ </td>
+ </tr>
+ <tr>
+ <th>Tier</th>
+ <td colSpan='2'>{config.tier}</td>
+ </tr>
+ <tr>
+ <th>Service</th>
+ <td colSpan='2'>{config.isService ? 'true' : 'false'}</td>
+ </tr>
+ <tr>
+ <th>Metadata</th>
+ <td colSpan='2'>
+ {config.metadata.map((m) => <span key={m.key}>{m.key}: {m.value}</span>)}
+ </td>
+ </tr>
+ <tr>
+ <th>Contact</th>
+ <td colSpan='2'>{config.contactEmail}</td>
+ </tr>
+ </tbody>
+ </table>);
+}
http://git-wip-us.apache.org/repos/asf/aurora/blob/2aee90d0/ui/src/main/js/components/TaskList.js
----------------------------------------------------------------------
diff --git a/ui/src/main/js/components/TaskList.js b/ui/src/main/js/components/TaskList.js
new file mode 100644
index 0000000..5a61de8
--- /dev/null
+++ b/ui/src/main/js/components/TaskList.js
@@ -0,0 +1,78 @@
+import React from 'react';
+import { Link } from 'react-router-dom';
+
+import Pagination from 'components/Pagination';
+import { RelativeTime } from 'components/Time';
+import TaskStateMachine from 'components/TaskStateMachine';
+
+import { getClassForScheduleStatus, getDuration, getLastEventTime, isActive } from 'utils/Task';
+import { SCHEDULE_STATUS } from 'utils/Thrift';
+
+export class TaskListItem extends React.Component {
+ constructor(props) {
+ super(props);
+ this.state = {expand: props.expand || false};
+ }
+
+ toggleExpand() {
+ this.setState({expanded: !this.state.expanded});
+ }
+
+ render() {
+ const task = this.props.task;
+ const { role, environment, name } = task.assignedTask.task.job;
+ const latestEvent = task.taskEvents[task.taskEvents.length - 1];
+ const active = isActive(task);
+ const stateMachine = (this.state.expanded) ? <TaskStateMachine task={task} /> : '';
+ return (<tr className={this.state.expanded ? 'expanded' : ''}>
+ <td>
+ <div className='task-list-item-instance'>
+ <Link
+ to={`/beta/scheduler/${role}/${environment}/${name}/${task.assignedTask.instanceId}`}>
+ {task.assignedTask.instanceId}
+ </Link>
+ </div>
+ </td>
+ <td className='task-list-item-col'>
+ <div className='task-list-item'>
+ <span className='task-list-item-status'>
+ {SCHEDULE_STATUS[task.status]}
+ <span className='task-list-item-expander' onClick={(e) => this.toggleExpand()}>
+ ...
+ </span>
+ </span>
+ <span className={`img-circle ${getClassForScheduleStatus(task.status)}`} />
+ <span className='task-list-item-time'>
+ {active ? 'since' : ''} <RelativeTime ts={getLastEventTime(task)} />
+ </span>
+ {active ? ''
+ : <span className='task-list-item-duration'>(ran for {getDuration(task)})</span>}
+ <span className='task-list-item-message'>
+ {latestEvent.message}
+ </span>
+ </div>
+ {stateMachine}
+ </td>
+ <td>
+ <div className='task-list-item-host'>
+ <a href={`http://${task.assignedTask.slaveHost}:1338/task/${task.assignedTask.taskId}`}>
+ {task.assignedTask.slaveHost}
+ </a>
+ </div>
+ </td>
+ </tr>);
+ }
+}
+
+export default function TaskList({ tasks }) {
+ return (<div className='task-list'>
+ <table className='psuedo-table'>
+ <Pagination
+ data={tasks}
+ hideIfSinglePage
+ isTable
+ numberPerPage={25}
+ renderer={(t) => <TaskListItem key={t.assignedTask.taskId} task={t} />} />
+ </table>
+ </div>);
+}
http://git-wip-us.apache.org/repos/asf/aurora/blob/2aee90d0/ui/src/main/js/components/TaskStateMachine.js
----------------------------------------------------------------------
diff --git a/ui/src/main/js/components/TaskStateMachine.js b/ui/src/main/js/components/TaskStateMachine.js
new file mode 100644
index 0000000..4b1da90
--- /dev/null
+++ b/ui/src/main/js/components/TaskStateMachine.js
@@ -0,0 +1,10 @@
+import React from 'react';
+
+import StateMachine from 'components/StateMachine';
+
+import { getClassForScheduleStatus, taskToStateMachine } from 'utils/Task';
+
+export default function TaskStateMachine({ task }) {
+ const states = taskToStateMachine(task);
+ return <StateMachine className={getClassForScheduleStatus(task.status)} states={states} />;
+}
http://git-wip-us.apache.org/repos/asf/aurora/blob/2aee90d0/ui/src/main/js/components/UpdateList.js
----------------------------------------------------------------------
diff --git a/ui/src/main/js/components/UpdateList.js b/ui/src/main/js/components/UpdateList.js
index 3f57669..2df2839 100644
--- a/ui/src/main/js/components/UpdateList.js
+++ b/ui/src/main/js/components/UpdateList.js
@@ -9,8 +9,11 @@ import { isNully } from 'utils/Common';
import { UPDATE_STATUS } from 'utils/Thrift';
import { getClassForUpdateStatus } from 'utils/Update';
-function UpdateListItem({ summary }) {
+function UpdateListItem({ summary, titleFn }) {
const {job: {role, environment, name}, id} = summary.key;
+
+ const title = titleFn || ((u) => `${role}/${environment}/${name}`);
+
return (<div className='update-list-item'>
<span className={`img-circle ${getClassForUpdateStatus(summary.state.status)}`} />
<div className='update-list-item-details'>
@@ -18,7 +21,7 @@ function UpdateListItem({ summary }) {
<Link
className='update-list-job'
to={`/beta/scheduler/${role}/${environment}/${name}/update/${id}`}>
- {role}/{environment}/{name}
+ {title(summary)}
</Link> • <span className='update-list-status'>
{UPDATE_STATUS[summary.state.status]}
</span>
@@ -32,6 +35,16 @@ function UpdateListItem({ summary }) {
</div>);
}
+export function JobUpdateList({ updates }) {
+ if (isNully(updates)) {
+ return <Loading />;
+ }
+
+ return (<div className='update-list'>
+ {updates.map((u) => <UpdateListItem key={u.key.id} summary={u} titleFn={(u) => u.key.id} />)}
+ </div>);
+}
+
export default function UpdateList({ updates }) {
if (isNully(updates)) {
return <Loading />;
http://git-wip-us.apache.org/repos/asf/aurora/blob/2aee90d0/ui/src/main/js/components/UpdatePreview.js
----------------------------------------------------------------------
diff --git a/ui/src/main/js/components/UpdatePreview.js b/ui/src/main/js/components/UpdatePreview.js
new file mode 100644
index 0000000..73dd487
--- /dev/null
+++ b/ui/src/main/js/components/UpdatePreview.js
@@ -0,0 +1,30 @@
+import React from 'react';
+import { Link } from 'react-router-dom';
+
+import PanelGroup, { Container } from 'components/Layout';
+import { RelativeTime } from 'components/Time';
+
+import { getClassForUpdateStatus, updateStats } from 'utils/Update';
+
+export default function UpdatePreview({ update }) {
+ const stats = updateStats(update);
+ const {job: {role, environment, name}, id} = update.update.summary.key;
+ return (<Container>
+ <PanelGroup noPadding title=''>
+ <div
+ className={`update-preview ${getClassForUpdateStatus(update.update.summary.state.status)}`}>
+ <Link
+ to={`/beta/scheduler/${role}/${environment}/${name}/update/${id}`}>
+ Update In Progress
+ </Link>
+ <span className='update-preview-details'>
+ started by <strong>{update.update.summary.user}</strong> <span>
+ <RelativeTime ts={update.update.summary.state.createdTimestampMs} /></span>
+ </span>
+ <span className='update-preview-progress'>
+ {stats.instancesUpdated} / {stats.totalInstancesToBeUpdated} ({stats.progress}%)
+ </span>
+ </div>
+ </PanelGroup>
+ </Container>);
+}
http://git-wip-us.apache.org/repos/asf/aurora/blob/2aee90d0/ui/src/main/js/components/__tests__/Breadcrumb-test.js
----------------------------------------------------------------------
diff --git a/ui/src/main/js/components/__tests__/Breadcrumb-test.js b/ui/src/main/js/components/__tests__/Breadcrumb-test.js
index 47f7afb..0a9ad4b 100644
--- a/ui/src/main/js/components/__tests__/Breadcrumb-test.js
+++ b/ui/src/main/js/components/__tests__/Breadcrumb-test.js
@@ -8,25 +8,25 @@ import Breadcrumb from '../Breadcrumb';
describe('Breadcrumb', () => {
it('Should render cluster crumb', () => {
const el = shallow(<Breadcrumb cluster='devcluster' />);
- expect(el.contains(<Link to='/scheduler'>devcluster</Link>)).toBe(true);
+ expect(el.contains(<Link to='/beta/scheduler'>devcluster</Link>)).toBe(true);
expect(el.find(Link).length).toBe(1);
});
it('Should render role crumb', () => {
const el = shallow(<Breadcrumb cluster='devcluster' role='www-data' />);
- expect(el.contains(<Link to='/scheduler/www-data'>www-data</Link>)).toBe(true);
+ expect(el.contains(<Link to='/beta/scheduler/www-data'>www-data</Link>)).toBe(true);
expect(el.find(Link).length).toBe(2);
});
it('Should render env crumb', () => {
const el = shallow(<Breadcrumb cluster='devcluster' env='prod' role='www-data' />);
- expect(el.contains(<Link to='/scheduler/www-data/prod'>prod</Link>)).toBe(true);
+ expect(el.contains(<Link to='/beta/scheduler/www-data/prod'>prod</Link>)).toBe(true);
expect(el.find(Link).length).toBe(3);
});
it('Should render name crumb', () => {
const el = shallow(<Breadcrumb cluster='devcluster' env='prod' name='hello' role='www-data' />);
- expect(el.contains(<Link to='/scheduler/www-data/prod/hello'>hello</Link>)).toBe(true);
+ expect(el.contains(<Link to='/beta/scheduler/www-data/prod/hello'>hello</Link>)).toBe(true);
expect(el.find(Link).length).toBe(4);
});
});
http://git-wip-us.apache.org/repos/asf/aurora/blob/2aee90d0/ui/src/main/js/components/__tests__/ConfigDiff-test.js
----------------------------------------------------------------------
diff --git a/ui/src/main/js/components/__tests__/ConfigDiff-test.js b/ui/src/main/js/components/__tests__/ConfigDiff-test.js
new file mode 100644
index 0000000..3eaa78a
--- /dev/null
+++ b/ui/src/main/js/components/__tests__/ConfigDiff-test.js
@@ -0,0 +1,75 @@
+import React from 'react';
+import { shallow } from 'enzyme';
+
+import ConfigDiff from '../ConfigDiff';
+
+import { TaskConfigBuilder, createConfigGroup } from 'test-utils/TaskBuilders';
+
+describe('ConfigDiff', () => {
+ it('Should render an empty div when there are less than 2 config groups', () => {
+ const groups = [createConfigGroup(TaskConfigBuilder, [0, 0])];
+ const el = shallow(<ConfigDiff groups={groups} />);
+ expect(el.contains(<div />)).toBe(true);
+ });
+
+ it('Should not add change classes to diff viewer when configs are same', () => {
+ const groups = [
+ createConfigGroup(TaskConfigBuilder, [0, 0]),
+ createConfigGroup(TaskConfigBuilder, [1, 9])
+ ];
+ const el = shallow(<ConfigDiff groups={groups} />);
+ expect(el.find('span.removed').length).toBe(0);
+ expect(el.find('span.added').length).toBe(0);
+ });
+
+ it('Should add change classes to diff viewer when configs are not the same', () => {
+ const groups = [
+ createConfigGroup(TaskConfigBuilder, [0, 0]),
+ createConfigGroup(TaskConfigBuilder.tier('something-else'), [1, 9])
+ ];
+ const el = shallow(<ConfigDiff groups={groups} />);
+ expect(el.find('span.removed').length).toBe(1);
+ expect(el.find('span.added').length).toBe(1);
+ });
+
+ it('Should not show any config group dropdown when there are only two groups', () => {
+ const groups = [
+ createConfigGroup(TaskConfigBuilder, [0, 0]),
+ createConfigGroup(TaskConfigBuilder.tier('something-else'), [1, 9])
+ ];
+ const el = shallow(<ConfigDiff groups={groups} />);
+ expect(el.find('select').length).toBe(0);
+ });
+
+ it('Should show a group dropdown when there are more than two groups', () => {
+ const groups = [
+ createConfigGroup(TaskConfigBuilder, [0, 0]),
+ createConfigGroup(TaskConfigBuilder.tier('something-else'), [1, 1]),
+ createConfigGroup(TaskConfigBuilder.tier('something-else'), [2, 2])
+ ];
+ const el = shallow(<ConfigDiff groups={groups} />);
+ expect(el.find('select').length).toBe(2);
+ expect(el.find('option').length).toBe(4);
+ });
+
+ it('Should update the diff view when you select new groups', () => {
+ const groups = [
+ createConfigGroup(TaskConfigBuilder, [0, 0]),
+ createConfigGroup(TaskConfigBuilder.tier('something-else'), [1, 1]),
+ createConfigGroup(TaskConfigBuilder.tier('something-else'), [2, 2])
+ ];
+ const el = shallow(<ConfigDiff groups={groups} />);
+ expect(el.find('span.removed').length).toBe(1);
+ expect(el.find('span.added').length).toBe(1);
+
+ expect(el.find('.diff-before select').length).toBe(1);
+ expect(el.find('option').length).toBe(4);
+
+ // Change the left config to be index=2, which has the same config as index=1
+ el.find('.diff-before select').simulate('change', {target: {value: '2'}});
+ // Now assert the diff was updated!
+ expect(el.find('span.removed').length).toBe(0);
+ expect(el.find('span.added').length).toBe(0);
+ expect(el.find('option').length).toBe(4);
+ });
+});
http://git-wip-us.apache.org/repos/asf/aurora/blob/2aee90d0/ui/src/main/js/components/__tests__/JobConfig-test.js
----------------------------------------------------------------------
diff --git a/ui/src/main/js/components/__tests__/JobConfig-test.js b/ui/src/main/js/components/__tests__/JobConfig-test.js
new file mode 100644
index 0000000..59541d9
--- /dev/null
+++ b/ui/src/main/js/components/__tests__/JobConfig-test.js
@@ -0,0 +1,27 @@
+import React from 'react';
+import { shallow } from 'enzyme';
+
+import ConfigDiff from '../ConfigDiff';
+import JobConfig from '../JobConfig';
+import Loading from '../Loading';
+import TaskConfigSummary from '../TaskConfigSummary';
+
+import { TaskConfigBuilder, createConfigGroup } from 'test-utils/TaskBuilders';
+
+describe('JobConfig', () => {
+ it('Should render summaries and diff with configs in order of lowest instance id', () => {
+ const group0 = createConfigGroup(TaskConfigBuilder, [0, 0]);
+ const group1 = createConfigGroup(TaskConfigBuilder, [1, 9]);
+ const group2 = createConfigGroup(TaskConfigBuilder, [10, 10]);
+
+ const el = shallow(<JobConfig groups={[group2, group0, group1]} />);
+ const summaries = el.find(TaskConfigSummary).map((i) => i.props().instances);
+ expect(summaries).toEqual([group0.instances, group1.instances, group2.instances]);
+ expect(el.contains(<ConfigDiff groups={[group0, group1, group2]} />)).toBe(true);
+ });
+
+ it('Should render Loading when no groups are supplied', () => {
+ const el = shallow(<JobConfig />);
+ expect(el.contains(<Loading />)).toBe(true);
+ });
+});
http://git-wip-us.apache.org/repos/asf/aurora/blob/2aee90d0/ui/src/main/js/components/__tests__/Tabs-test.js
----------------------------------------------------------------------
diff --git a/ui/src/main/js/components/__tests__/Tabs-test.js b/ui/src/main/js/components/__tests__/Tabs-test.js
new file mode 100644
index 0000000..e028c2d
--- /dev/null
+++ b/ui/src/main/js/components/__tests__/Tabs-test.js
@@ -0,0 +1,39 @@
+import React from 'react';
+import { shallow } from 'enzyme';
+
+import Tabs from '../Tabs';
+
+const DummyTab = ({ number }) => <span>Hello, {number}</span>;
+
+const tabs = [
+ {name: 'one', content: <DummyTab number={1} />},
+ {name: 'two', content: <DummyTab number={2} />},
+ {name: 'three', content: <DummyTab number={3} />}
+];
+
+describe('Tabs', () => {
+ it('Should set the first tab to active by default', () => {
+ const el = shallow(<Tabs tabs={tabs} />);
+ expect(el.contains(<DummyTab number={1} />)).toBe(true);
+ expect(el.find(DummyTab).length).toBe(1);
+ expect(el.find('.active').key()).toBe('one');
+ });
+
+ it('Should allow you to specify a default via props', () => {
+ const el = shallow(<Tabs activeTab='two' tabs={tabs} />);
+ expect(el.contains(<DummyTab number={2} />)).toBe(true);
+ expect(el.find(DummyTab).length).toBe(1);
+ expect(el.find('.active').key()).toBe('two');
+ });
+
+ it('Should switch tabs on click', () => {
+ const el = shallow(<Tabs tabs={tabs} />);
+ expect(el.contains(<DummyTab number={1} />)).toBe(true);
+ expect(el.find(DummyTab).length).toBe(1);
+ expect(el.find('.active').key()).toBe('one');
+
+ el.find('li').at(2).simulate('click');
+ expect(el.contains(<DummyTab number={3} />)).toBe(true);
+ expect(el.find('.active').key()).toBe('three');
+ });
+});
http://git-wip-us.apache.org/repos/asf/aurora/blob/2aee90d0/ui/src/main/js/components/__tests__/TaskList-test.js
----------------------------------------------------------------------
diff --git a/ui/src/main/js/components/__tests__/TaskList-test.js b/ui/src/main/js/components/__tests__/TaskList-test.js
new file mode 100644
index 0000000..ae74ff4
--- /dev/null
+++ b/ui/src/main/js/components/__tests__/TaskList-test.js
@@ -0,0 +1,22 @@
+import React from 'react';
+import { shallow } from 'enzyme';
+
+import { TaskListItem } from '../TaskList';
+import TaskStateMachine from '../TaskStateMachine';
+
+import { ScheduledTaskBuilder } from 'test-utils/TaskBuilders';
+
+describe('TaskListItem', () => {
+ it('Should not show any state machine element by default', () => {
+ const el = shallow(<TaskListItem task={ScheduledTaskBuilder.build()} />);
+ expect(el.find(TaskStateMachine).length).toBe(0);
+ expect(el.find('tr.expanded').length).toBe(0);
+ });
+
+ it('Should show the state machine and add expanded to row when expand link is clicked', () => {
+ const el = shallow(<TaskListItem task={ScheduledTaskBuilder.build()} />);
+ el.find('.task-list-item-expander').simulate('click');
+ expect(el.find(TaskStateMachine).length).toBe(1);
+ expect(el.find('tr.expanded').length).toBe(1);
+ });
+});
http://git-wip-us.apache.org/repos/asf/aurora/blob/2aee90d0/ui/src/main/js/index.js
----------------------------------------------------------------------
diff --git a/ui/src/main/js/index.js b/ui/src/main/js/index.js
index 30646f7..13d722f 100644
--- a/ui/src/main/js/index.js
+++ b/ui/src/main/js/index.js
@@ -6,6 +6,7 @@ import SchedulerClient from 'client/scheduler-client';
import Navigation from 'components/Navigation';
import Home from 'pages/Home';
import Instance from 'pages/Instance';
+import Job from 'pages/Job';
import Jobs from 'pages/Jobs';
import Update from 'pages/Update';
import Updates from 'pages/Updates';
@@ -21,7 +22,7 @@ const SchedulerUI = () => (
<Route component={injectApi(Home)} exact path='/beta/scheduler' />
<Route component={injectApi(Jobs)} exact path='/beta/scheduler/:role' />
<Route component={injectApi(Jobs)} exact path='/beta/scheduler/:role/:environment' />
- <Route component={Home} exact path='/beta/scheduler/:role/:environment/:name' />
+ <Route component={injectApi(Job)} exact path='/beta/scheduler/:role/:environment/:name' />
<Route
component={injectApi(Instance)}
exact
http://git-wip-us.apache.org/repos/asf/aurora/blob/2aee90d0/ui/src/main/js/pages/Job.js
----------------------------------------------------------------------
diff --git a/ui/src/main/js/pages/Job.js b/ui/src/main/js/pages/Job.js
new file mode 100644
index 0000000..fc400f7
--- /dev/null
+++ b/ui/src/main/js/pages/Job.js
@@ -0,0 +1,146 @@
+import React from 'react';
+
+import Breadcrumb from 'components/Breadcrumb';
+import JobConfig from 'components/JobConfig';
+import PanelGroup, { Container, StandardPanelTitle } from 'components/Layout';
+import Loading from 'components/Loading';
+import Tabs from 'components/Tabs';
+import TaskList from 'components/TaskList';
+import { JobUpdateList } from 'components/UpdateList';
+import UpdatePreview from 'components/UpdatePreview';
+
+import { isNully, sort } from 'utils/Common';
+import { getLastEventTime, isActive } from 'utils/Task';
+import { isInProgressUpdate } from 'utils/Update';
+
+export default class Job extends React.Component {
+ constructor(props) {
+ super(props);
+ this.state = {
+ cluster: props.cluster || '',
+ configGroups: props.configGroups,
+ tasks: props.tasks,
+ updates: props.updates,
+ pendingReasons: props.pendingReasons
+ };
+ }
+
+ componentWillMount() {
+ const {api, match: {params: {role, environment, name}}} = this.props;
+ const that = this;
+ const key = new JobKey({role, environment, name});
+
+ const taskQuery = new TaskQuery();
+ taskQuery.role = role;
+ taskQuery.environment = environment;
+ taskQuery.jobName = name;
+ api.getTasksWithoutConfigs(taskQuery, (response) => {
+ that.setState({
+ cluster: response.serverInfo.clusterName,
+ tasks: response.result.scheduleStatusResult.tasks
+ });
+ });
+ api.getPendingReason(taskQuery, (response) => {
+ that.setState({
+ cluster: response.serverInfo.clusterName,
+ pendingReasons: response.result.getPendingReasonResult.reasons
+ });
+ });
+ api.getConfigSummary(key, (response) => {
+ that.setState({
+ cluster: response.serverInfo.clusterName,
+ configGroups: response.result.configSummaryResult.summary.groups
+ });
+ });
+
+ const updateQuery = new JobUpdateQuery();
+ updateQuery.jobKey = key;
+ api.getJobUpdateDetails(null, updateQuery, (response) => {
+ that.setState({
+ cluster: response.serverInfo.clusterName,
+ updates: response.result.getJobUpdateDetailsResult.detailsList
+ });
+ });
+ }
+
+ updateInProgress() {
+ if (!this.state.updates) {
+ return '';
+ }
+
+ const updateInProgress = this.state.updates.find(isInProgressUpdate);
+ if (!updateInProgress) {
+ return '';
+ }
+ return <UpdatePreview update={updateInProgress} />;
+ }
+
+ updateHistory() {
+ if (!this.state.updates || this.state.updates.length === 0) {
+ return '';
+ }
+
+ const terminalUpdates = this.state.updates
+ .filter((u) => !isInProgressUpdate(u))
+ .map((u) => u.update.summary);
+
+ if (terminalUpdates.length === 0) {
+ return '';
+ }
+
+ return (<Container>
+ <PanelGroup noPadding title={<StandardPanelTitle title='Update History' />}>
+ <JobUpdateList updates={terminalUpdates} />
+ </PanelGroup>
+ </Container>);
+ }
+
+ jobHistoryTab() {
+ const terminalTasks = sort(
+ this.state.tasks.filter((t) => !isActive(t)), (t) => getLastEventTime(t), true);
+
+ return {
+ name: `Job History (${terminalTasks.length})`,
+ content: <PanelGroup><TaskList tasks={terminalTasks} /></PanelGroup>
+ };
+ }
+
+ jobStatusTab() {
+ const activeTasks = sort(this.state.tasks.filter(isActive), (t) => t.assignedTask.instanceId);
+ const numberConfigs = isNully(this.state.configGroups) ? '' : this.state.configGroups.length;
+ return {
+ name: 'Job Status',
+ content: (<PanelGroup>
+ <Tabs className='task-status-tabs' tabs={[
+ {icon: 'th-list', name: 'Tasks', content: <TaskList tasks={activeTasks} />},
+ {
+ icon: 'info-sign',
+ name: `Configuration (${numberConfigs})`,
+ content: <JobConfig groups={this.state.configGroups} />
+ }]} />
+ </PanelGroup>)
+ };
+ }
+
+ jobOverview() {
+ if (isNully(this.state.tasks)) {
+ return <Loading />;
+ }
+ return <Tabs className='job-overview' tabs={[this.jobStatusTab(), this.jobHistoryTab()]} />;
+ }
+
+ render() {
+ return (<div className='job-page'>
+ <Breadcrumb
+ cluster={this.state.cluster}
+ env={this.props.match.params.environment}
+ name={this.props.match.params.name}
+ role={this.props.match.params.role} />
+ {this.updateInProgress()}
+ <Container>
+ {this.jobOverview()}
+ </Container>
+ {this.updateHistory()}
+ </div>);
+ }
+}
http://git-wip-us.apache.org/repos/asf/aurora/blob/2aee90d0/ui/src/main/js/pages/__tests__/Job-test.js
----------------------------------------------------------------------
diff --git a/ui/src/main/js/pages/__tests__/Job-test.js b/ui/src/main/js/pages/__tests__/Job-test.js
new file mode 100644
index 0000000..4cc76b8
--- /dev/null
+++ b/ui/src/main/js/pages/__tests__/Job-test.js
@@ -0,0 +1,101 @@
+import React from 'react';
+import { shallow } from 'enzyme';
+
+import Job from '../Job';
+
+import Breadcrumb from 'components/Breadcrumb';
+import Loading from 'components/Loading';
+import Tabs from 'components/Tabs';
+import { JobUpdateList } from 'components/UpdateList';
+import UpdatePreview from 'components/UpdatePreview';
+
+import { ScheduledTaskBuilder } from 'test-utils/TaskBuilders';
+import { builderWithStatus } from 'test-utils/UpdateBuilders';
+
+const params = {
+ role: 'test-role',
+ environment: 'test-env',
+ name: 'test-job'
+};
+
+function apiSpy() {
+ return {
+ getTasksWithoutConfigs: jest.fn(),
+ getPendingReason: jest.fn(),
+ getConfigSummary: jest.fn(),
+ getJobUpdateDetails: jest.fn()
+ };
+}
+
+describe('Update', () => {
+ // basic props to force render of all components
+ const props = (tasks = []) => {
+ return {api: apiSpy(), cluster: 'test', match: {params: params}, tasks: tasks};
+ };
+
+ it('Should render Loading and fire off calls for data', () => {
+ const api = apiSpy();
+ expect(shallow(<Job api={api} match={{params: params}} />)
+ .contains(<Loading />)).toBe(true);
+ Object.keys(api).forEach((apiKey) => expect(api[apiKey].mock.calls.length).toBe(1));
+ });
+
+ it('Should render breadcrumb with correct values', () => {
+ const el = shallow(<Job api={apiSpy()} cluster='test' match={{params: params}} tasks={[]} />);
+ expect(el.contains(<Breadcrumb
+ cluster='test'
+ env={params.environment}
+ name={params.name}
+ role={params.role} />)).toBe(true);
+ });
+
+ it('Should show UpdatePreview if in-progress update exists', () => {
+ const updates = [builderWithStatus(JobUpdateStatus.ROLLING_FORWARD).build()];
+ const el = shallow(<Job {...props()} updates={updates} />);
+ expect(el.contains(<UpdatePreview update={updates[0]} />)).toBe(true);
+ });
+
+ it('Should not show UpdatePreview if no in-progress update exists', () => {
+ const updates = [builderWithStatus(JobUpdateStatus.ROLLED_FORWARD).build()];
+ const el = shallow(<Job {...props()} updates={updates} />);
+ expect(el.find(UpdatePreview).length).toBe(0);
+ });
+
+ it('Should render JobUpdateList with any terminal update summaries', () => {
+ const updates = [
+ builderWithStatus(JobUpdateStatus.ROLLING_FORWARD).build(),
+ builderWithStatus(JobUpdateStatus.ROLLED_FORWARD).build(),
+ builderWithStatus(JobUpdateStatus.ROLLED_BACK).build()
+ ];
+ const el = shallow(<Job {...props()} updates={updates} />);
+ expect(el.contains(<JobUpdateList
+ updates={[updates[1].update.summary, updates[2].update.summary]} />)).toBe(true);
+ });
+
+ it('Should not render JobUpdateList if no terminal updates', () => {
+ const updates = [builderWithStatus(JobUpdateStatus.ROLLING_FORWARD).build()];
+ const el = shallow(<Job {...props()} updates={updates} />);
+ expect(el.find(JobUpdateList).length).toBe(0);
+ });
+
+ it('Should render task list with active tasks only on Job Status tab', () => {
+ const tasks = [
+ ScheduledTaskBuilder.status(ScheduleStatus.PENDING).build(),
+ ScheduledTaskBuilder.status(ScheduleStatus.FINISHED).build()
+ ];
+ const el = shallow(<Job {...props(tasks)} />);
+ const taskList = el.find(Tabs).props().tabs[0].content.props.children.props.tabs[0].content;
+ expect(taskList.props.tasks).toEqual([tasks[0]]);
+ });
+
+ it('Should render task list with terminal tasks only on Job History tab', () => {
+ const tasks = [
+ ScheduledTaskBuilder.status(ScheduleStatus.PENDING).build(),
+ ScheduledTaskBuilder.status(ScheduleStatus.FINISHED).build()
+ ];
+ const el = shallow(<Job {...props(tasks)} />);
+ expect(el.find(Tabs).props().tabs[1].name).toEqual('Job History (1)');
+ const taskList = el.find(Tabs).props().tabs[1].content.props.children;
+ expect(taskList.props.tasks).toEqual([tasks[1]]);
+ });
+});
http://git-wip-us.apache.org/repos/asf/aurora/blob/2aee90d0/ui/src/main/js/test-utils/TaskBuilders.js
----------------------------------------------------------------------
diff --git a/ui/src/main/js/test-utils/TaskBuilders.js b/ui/src/main/js/test-utils/TaskBuilders.js
index 35b9152..8427722 100644
--- a/ui/src/main/js/test-utils/TaskBuilders.js
+++ b/ui/src/main/js/test-utils/TaskBuilders.js
@@ -55,3 +55,10 @@ export const ScheduledTaskBuilder = createBuilder({
taskEvents: [TaskEventBuilder.build()],
ancestorId: ''
});
+
+export function createConfigGroup(taskBuilder, ...instances) {
+ return {
+ config: taskBuilder.build(),
+ instances: instances.map((pair) => { return {first: pair[0], last: pair[1]}; })
+ };
+}
http://git-wip-us.apache.org/repos/asf/aurora/blob/2aee90d0/ui/src/main/js/utils/Task.js
----------------------------------------------------------------------
diff --git a/ui/src/main/js/utils/Task.js b/ui/src/main/js/utils/Task.js
index c58ff7d..3259623 100644
--- a/ui/src/main/js/utils/Task.js
+++ b/ui/src/main/js/utils/Task.js
@@ -1,4 +1,5 @@
import moment from 'moment';
+import { isNully } from 'utils/Common';
import ThriftUtils, { SCHEDULE_STATUS } from 'utils/Thrift';
export function isActive(task) {
@@ -41,3 +42,25 @@ export function getDuration(task) {
const latestEvent = moment(task.taskEvents[task.taskEvents.length - 1].timestamp);
return moment.duration(latestEvent.diff(firstEvent)).humanize();
}
+
+export function instanceRangeToString(ranges) {
+ return ranges.map(({first, last}) => (first === last) ? first : `${first} - ${last}`);
+}
+
+export function getActiveResource(resource) {
+ return Object.keys(resource).find((r) => !isNully(resource[r]));
+}
+
+export function constraintToString(constraint) {
+ return isNully(constraint.value)
+ ? `limit=${constraint.limit.limit}`
+ : constraint.value.values.join(',');
+}
+
+export function getResource(resources, key) {
+ return resources.find((r) => !isNully(r[key]));
+}
+
+export function getResources(resources, key) {
+ return resources.filter((r) => !isNully(r[key]));
+}
http://git-wip-us.apache.org/repos/asf/aurora/blob/2aee90d0/ui/src/main/sass/app.scss
----------------------------------------------------------------------
diff --git a/ui/src/main/sass/app.scss b/ui/src/main/sass/app.scss
index 315b666..3a799b6 100644
--- a/ui/src/main/sass/app.scss
+++ b/ui/src/main/sass/app.scss
@@ -11,10 +11,12 @@
@import 'components/state-machine';
@import 'components/status';
@import 'components/tables';
+@import 'components/task-list';
@import 'components/update-list';
/* Page Styles */
@import 'components/home-page';
@import 'components/instance-page';
+@import 'components/job-page';
@import 'components/job-list-page';
@import 'components/update-page';
\ No newline at end of file
http://git-wip-us.apache.org/repos/asf/aurora/blob/2aee90d0/ui/src/main/sass/components/_job-page.scss
----------------------------------------------------------------------
diff --git a/ui/src/main/sass/components/_job-page.scss b/ui/src/main/sass/components/_job-page.scss
new file mode 100644
index 0000000..8523fcf
--- /dev/null
+++ b/ui/src/main/sass/components/_job-page.scss
@@ -0,0 +1,158 @@
+.job-page {
+ .job-overview {
+ .tab-navigation {
+ background-color: rgba(0, 0, 0, 0.01);
+ list-style-type: none;
+ padding: 0;
+ margin: 0;
+
+ li {
+ cursor: hand;
+ cursor: pointer;
+ font-weight: 700;
+ padding: 15px 20px;
+ font-size: 18px;
+ color: $secondary_font_color;
+ display: inline-block;
+ }
+
+ li.active {
+ cursor: default;
+ color: #555;
+ background-color: $content_box_color;
+ }
+ }
+
+ .content-panel-group {
+ margin: 0 !important;
+ }
+ }
+
+ .task-status-tabs {
+ .tab-navigation {
+ border: 0;
+ background-color: $content_box_color;
+ margin-bottom: 10px;
+
+ .glyphicon {
+ font-size: 0.8em;
+ margin-right: 5px;
+ }
+
+ li {
+ padding: 5px 15px 0px 15px;
+ text-transform: uppercase;
+ font-size: 14px;
+ }
+
+ li.active {
+ color: steelblue;
+ border: 0;
+ }
+ }
+ }
+
+ .job-configuration-summaries {
+ display: flex;
+ flex-wrap: wrap;
+ font-size: 13px;
+
+ .task-config-summary {
+ width: 350px;
+ border: 1px solid $grid_color;
+ margin-right: 20px;
+ margin-bottom: 20px;
+ }
+
+ tr:first-child {
+ background-color: $grid_color;
+ }
+
+ th {
+ text-transform: uppercase;
+ }
+ }
+
+ .task-diff {
+ font-family: 'Courier', sans-serif;
+ font-size: 12px;
+ border: 1px solid $grid_highlight_color;
+
+ .diff-picker {
+ font-family: $font_stack;
+ background-color: $grid_color;
+ border-bottom: 1px solid $grid_highlight_color;
+ text-transform: uppercase;
+ padding: 15px 20px 5px 20px;
+ color: #555;
+ font-size: 14px;
+ font-weight: 700;
+
+ div {
+ font-weight: 700;
+ margin: 0px 10px;
+ }
+ }
+
+ .diff-view {
+ span {
+ display: block;
+ padding: 2px 10px;
+ width: 100%;
+ background-color: $content_box_color;
+ }
+
+ span.same {
+ background-color: rgba(0, 0, 0, 0.01);
+ }
+
+ span.removed {
+ background-color: $colors_error_light;
+ }
+
+ span.added {
+ background-color: $colors_success_light;
+ }
+ }
+ }
+
+ .update-preview {
+ padding: 20px;
+ color: white;
+ align-items: center;
+ display: flex;
+ justify-content: space-between;
+
+ &.in-progress {
+ background-color: $colors_highlight;
+ }
+
+ &.attention {
+ background-color: $colors_warning;
+ }
+
+ &.okay {
+ background-color: $colors_success;
+ }
+
+ &.error {
+ background-color: $colors_error;
+ }
+
+ a {
+ color: white;
+ font-size: 30px;
+ text-transform: uppercase;
+ font-weight: 700;
+ }
+
+ .update-preview-details {
+ text-transform: uppercase;
+ }
+
+ .update-preview-progress {
+ font-weight: 700;
+ font-size: 24px;
+ }
+ }
+}
\ No newline at end of file
http://git-wip-us.apache.org/repos/asf/aurora/blob/2aee90d0/ui/src/main/sass/components/_task-list.scss
----------------------------------------------------------------------
diff --git a/ui/src/main/sass/components/_task-list.scss b/ui/src/main/sass/components/_task-list.scss
new file mode 100644
index 0000000..a6e2f0a
--- /dev/null
+++ b/ui/src/main/sass/components/_task-list.scss
@@ -0,0 +1,91 @@
+.task-list {
+ margin: 10px 0;
+ border: 1px solid $grid_color !important;
+
+ table {
+ font-size: 14px;
+ margin: 0;
+ border: 0;
+
+ tr:first-child {
+ border-top: 0;
+ }
+
+ tr:last-child {
+ border-bottom: 0;
+ }
+ }
+
+ .task-list-item-col {
+ width: 99%;
+ }
+
+ td {
+ padding: 5px;
+ }
+
+ td:first-child {
+ text-align: center;
+ }
+
+ .expanded:hover {
+ background-color: $content_box_color !important;
+ }
+
+ .task-list-item-instance {
+ padding: 5px;
+ a {
+ font-size: 20px;
+ font-weight: 800 !important;
+ }
+ }
+
+ .task-list-item-expander {
+ margin: 0px 5px;
+ font-weight: 800;
+ line-height: 10px;
+ font-size: 14px;
+ cursor: hand;
+ cursor: pointer;
+ text-decoration: underline;
+ }
+
+ .task-list-item-host {
+ white-space: nowrap;
+ margin: 0px 5px;
+ }
+
+ .img-circle {
+ margin: 0px 5px;
+ width: 6px;
+ height: 6px;
+ }
+
+ .task-list-item {
+ align-items: center;
+ padding: 10px 0;
+
+ .task-list-item-time {
+ color: $secondary_font_color;
+ }
+
+ .task-list-item-duration {
+ color: $secondary_font_color;
+ margin-left: 5px;
+ }
+
+ .task-list-item-status {
+ font-weight: 600;
+ }
+
+ .task-list-item-message {
+ display: block;
+ max-width: 500px;
+ font-size: 12px;
+ white-space: nowrap;
+ max-width: 500px;
+ text-overflow: ellipsis;
+ overflow: hidden;
+ }
+ }
+}
\ No newline at end of file