Browse Source

HUE-869 [oozie] Bundle coordinators

Create, edit, copy, bundles
Add, edit, delete coordinator with specific parameters to the bundle
Bundles can be deployed and subimitted to Oozie
Running bundles can be paused or killed
Dashboard page lists bundles, specific bundle
Workflow action page prettified in the dashboard
Dashboard now have the whole hierarchy for Bundle, Coordinator, Workflow, Action as breadcrumbs
Oozie API updated for supporting bundles
Models now have Bundles
DB Migration enclosed
Rerun of a Bundle will be done in another jira
Add test for all above
Romain Rigaux 13 years ago
parent
commit
d02aadd677
28 changed files with 3050 additions and 229 deletions
  1. 10 22
      apps/oozie/src/oozie/decorators.py
  2. 1 1
      apps/oozie/src/oozie/fixtures/initial_oozie_examples.json
  3. 30 1
      apps/oozie/src/oozie/forms.py
  4. 333 0
      apps/oozie/src/oozie/migrations/0017_auto__add_bundledcoordinator__add_bundle.py
  5. 86 0
      apps/oozie/src/oozie/models.py
  6. 472 0
      apps/oozie/src/oozie/templates/dashboard/list_oozie_bundle.mako
  7. 389 0
      apps/oozie/src/oozie/templates/dashboard/list_oozie_bundles.mako
  8. 8 3
      apps/oozie/src/oozie/templates/dashboard/list_oozie_coordinator.mako
  9. 5 6
      apps/oozie/src/oozie/templates/dashboard/list_oozie_workflow.mako
  10. 120 93
      apps/oozie/src/oozie/templates/dashboard/list_oozie_workflow_action.mako
  11. 69 0
      apps/oozie/src/oozie/templates/editor/create_bundle.mako
  12. 54 0
      apps/oozie/src/oozie/templates/editor/create_bundled_coordinator.mako
  13. 3 3
      apps/oozie/src/oozie/templates/editor/create_coordinator_dataset.mako
  14. 527 0
      apps/oozie/src/oozie/templates/editor/edit_bundle.mako
  15. 72 0
      apps/oozie/src/oozie/templates/editor/edit_bundled_coordinator.mako
  16. 2 2
      apps/oozie/src/oozie/templates/editor/edit_coordinator.mako
  17. 4 1
      apps/oozie/src/oozie/templates/editor/edit_coordinator_dataset.mako
  18. 8 8
      apps/oozie/src/oozie/templates/editor/edit_workflow.mako
  19. 53 0
      apps/oozie/src/oozie/templates/editor/gen/bundle.xml.mako
  20. 1 1
      apps/oozie/src/oozie/templates/editor/gen/workflow-graph-status.xml.mako
  21. 245 0
      apps/oozie/src/oozie/templates/editor/list_bundles.mako
  22. 2 0
      apps/oozie/src/oozie/templates/navigation-bar.mako
  23. 10 2
      apps/oozie/src/oozie/tests.py
  24. 15 3
      apps/oozie/src/oozie/urls.py
  25. 142 12
      apps/oozie/src/oozie/views/dashboard.py
  26. 205 6
      apps/oozie/src/oozie/views/editor.py
  27. 12 7
      desktop/libs/liboozie/src/liboozie/oozie_api.py
  28. 172 58
      desktop/libs/liboozie/src/liboozie/types.py

+ 10 - 22
apps/oozie/src/oozie/decorators.py

@@ -17,27 +17,11 @@
 
 import logging
 
-
-from django.core.urlresolvers import reverse
-from django.db.models import Q
-from django.forms.formsets import formset_factory
-from django.forms.models import inlineformset_factory, modelformset_factory
-from django.http import HttpResponse
-from django.shortcuts import redirect
-from django.utils.functional import curry, wraps
-from django.utils.translation import ugettext as _
-
-from desktop.lib.django_util import render, extract_field_data
+from django.utils.functional import wraps
 from desktop.lib.exceptions_renderable import PopupException
-from desktop.lib.rest.http_client import RestException
-from hadoop.fs.exceptions import WebHdfsException
-from jobsub.models import OozieDesign
-from liboozie.submittion import Submission
 
-from oozie.models import Job, Workflow, Node, Link, History, Coordinator,\
-  Mapreduce, Java, Streaming, Pig, Hive, Sqoop, Ssh, Shell, DistCp, Decision, Dataset
-from oozie.forms import NodeForm, WorkflowForm, CoordinatorForm, DatasetForm,\
-  DefaultLinkForm, design_form_by_type, ImportJobsubDesignForm, ParameterForm
+from oozie.models import Job, Node, Dataset
+
 
 LOG = logging.getLogger(__name__)
 
@@ -55,8 +39,10 @@ def check_job_access_permission(exception_class=PopupException):
     def decorate(request, *args, **kwargs):
       if 'workflow' in kwargs:
         job_type = 'workflow'
-      else:
+      elif 'coordinator' in kwargs:
         job_type = 'coordinator'
+      else:
+        job_type = 'bundle'
 
       job = kwargs.get(job_type)
       if job is not None:
@@ -78,8 +64,10 @@ def check_job_edition_permission(authorize_get=False, exception_class=PopupExcep
     def decorate(request, *args, **kwargs):
       if 'workflow' in kwargs:
         job_type = 'workflow'
-      else:
+      elif 'coordinator' in kwargs:
         job_type = 'coordinator'
+      else:
+        job_type = 'bundle'
 
       job = kwargs.get(job_type)
       if job is not None and not (authorize_get and request.method == 'GET'):
@@ -157,4 +145,4 @@ def check_dataset_edition_permission(authorize_get=False):
 
       return view_func(request, *args, **kwargs)
     return wraps(view_func)(decorate)
-  return inner
+  return inner

+ 1 - 1
apps/oozie/src/oozie/fixtures/initial_oozie_examples.json

@@ -189,7 +189,7 @@
       "name": "DailyAnalytics",
       "parameters": "[]",
       "deployment_dir": "/user/hue/oozie/workspaces/range",
-      "schema_version": "uri:oozie:coordinator:0.1",
+      "schema_version": "uri:oozie:coordinator:0.2",
       "last_modified": "2013-01-01 00:00:00",
       "owner": 1100713,
       "description": "Run daily a workflow with a date range of input data"

+ 30 - 1
apps/oozie/src/oozie/forms.py

@@ -27,7 +27,7 @@ from django.utils.translation import ugettext_lazy as _t
 from desktop.lib.django_forms import MultiForm, SplitDateTimeWidget
 from oozie.models import Workflow, Node, Java, Mapreduce, Streaming, Coordinator,\
   Dataset, DataInput, DataOutput, Pig, Link, Hive, Sqoop, Ssh, Shell, DistCp, Fs,\
-  Email, SubWorkflow, Generic
+  Email, SubWorkflow, Generic, Bundle, BundledCoordinator
 
 
 LOG = logging.getLogger(__name__)
@@ -452,6 +452,35 @@ class RerunCoordForm(forms.Form):
     self.fields['actions'].choices = [(action.actionNumber, action.title) for action in reversed(oozie_coordinator.get_working_actions())]
 
 
+class BundledCoordinatorForm(forms.ModelForm):
+
+  def __init__(self, *args, **kwargs):
+    super(BundledCoordinatorForm, self).__init__(*args, **kwargs)
+    self.fields['coordinator'].empty_label = None
+
+  class Meta:
+    model = BundledCoordinator
+    exclude = ('bundle',)
+    widgets = {
+      'parameters': forms.widgets.HiddenInput(),
+    }
+
+
+class BundleForm(forms.ModelForm):
+  kick_off_time = forms.SplitDateTimeField(input_time_formats=[TIME_FORMAT],
+                                           widget=SplitDateTimeWidget(attrs={'class': 'input-small', 'id': 'bundle_kick_off_time'},
+                                                                      date_format=DATE_FORMAT, time_format=TIME_FORMAT))
+
+  class Meta:
+    model = Bundle
+    exclude = ('owner', 'coordinators')
+    widgets = {
+      'description': forms.TextInput(attrs={'class': 'span5'}),
+      'parameters': forms.widgets.HiddenInput(),
+      'schema_version': forms.widgets.HiddenInput(),
+    }
+
+
 def design_form_by_type(node_type, user, workflow):
   klass_form = _node_type_TO_FORM_CLS[node_type]
 

+ 333 - 0
apps/oozie/src/oozie/migrations/0017_auto__add_bundledcoordinator__add_bundle.py

@@ -0,0 +1,333 @@
+# encoding: utf-8
+import datetime
+from south.db import db
+from south.v2 import SchemaMigration
+from django.db import models
+
+class Migration(SchemaMigration):
+    
+    def forwards(self, orm):
+        
+        # Adding model 'BundledCoordinator'
+        db.create_table('oozie_bundledcoordinator', (
+            ('coordinator', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['oozie.Coordinator'])),
+            ('parameters', self.gf('django.db.models.fields.TextField')(default='[]')),
+            ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
+            ('bundle', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['oozie.Bundle'])),
+        ))
+        db.send_create_signal('oozie', ['BundledCoordinator'])
+
+        # Adding model 'Bundle'
+        db.create_table('oozie_bundle', (
+            ('kick_off_time', self.gf('django.db.models.fields.DateTimeField')(default=datetime.datetime(2013, 2, 13, 22, 26, 34, 626668))),
+            ('job_ptr', self.gf('django.db.models.fields.related.OneToOneField')(to=orm['oozie.Job'], unique=True, primary_key=True)),
+        ))
+        db.send_create_signal('oozie', ['Bundle'])
+    
+    
+    def backwards(self, orm):
+        
+        # Deleting model 'BundledCoordinator'
+        db.delete_table('oozie_bundledcoordinator')
+
+        # Deleting model 'Bundle'
+        db.delete_table('oozie_bundle')
+    
+    
+    models = {
+        'auth.group': {
+            'Meta': {'object_name': 'Group'},
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}),
+            'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'})
+        },
+        'auth.permission': {
+            'Meta': {'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'},
+            'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
+            'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'name': ('django.db.models.fields.CharField', [], {'max_length': '50'})
+        },
+        'auth.user': {
+            'Meta': {'object_name': 'User'},
+            'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
+            'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}),
+            'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
+            'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True', 'blank': 'True'}),
+            'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False', 'blank': 'True'}),
+            'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False', 'blank': 'True'}),
+            'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
+            'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
+            'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}),
+            'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}),
+            'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'})
+        },
+        'contenttypes.contenttype': {
+            'Meta': {'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"},
+            'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
+            'name': ('django.db.models.fields.CharField', [], {'max_length': '100'})
+        },
+        'oozie.bundle': {
+            'Meta': {'object_name': 'Bundle', '_ormbases': ['oozie.Job']},
+            'coordinators': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['oozie.Coordinator']", 'through': "orm['oozie.BundledCoordinator']", 'symmetrical': 'False'}),
+            'job_ptr': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['oozie.Job']", 'unique': 'True', 'primary_key': 'True'}),
+            'kick_off_time': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime(2013, 2, 13, 22, 26, 34, 626668)'})
+        },
+        'oozie.bundledcoordinator': {
+            'Meta': {'object_name': 'BundledCoordinator'},
+            'bundle': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['oozie.Bundle']"}),
+            'coordinator': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['oozie.Coordinator']"}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'parameters': ('django.db.models.fields.TextField', [], {'default': "'[]'"})
+        },
+        'oozie.coordinator': {
+            'Meta': {'object_name': 'Coordinator', '_ormbases': ['oozie.Job']},
+            'concurrency': ('django.db.models.fields.PositiveSmallIntegerField', [], {'null': 'True', 'blank': 'True'}),
+            'end': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime(2013, 2, 16, 22, 26, 34, 624131)'}),
+            'execution': ('django.db.models.fields.CharField', [], {'max_length': '10', 'null': 'True', 'blank': 'True'}),
+            'frequency_number': ('django.db.models.fields.SmallIntegerField', [], {'default': '1'}),
+            'frequency_unit': ('django.db.models.fields.CharField', [], {'default': "'days'", 'max_length': '20'}),
+            'job_properties': ('django.db.models.fields.TextField', [], {'default': "'[]'"}),
+            'job_ptr': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['oozie.Job']", 'unique': 'True', 'primary_key': 'True'}),
+            'start': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime(2013, 2, 13, 22, 26, 34, 624101)'}),
+            'throttle': ('django.db.models.fields.PositiveSmallIntegerField', [], {'null': 'True', 'blank': 'True'}),
+            'timeout': ('django.db.models.fields.SmallIntegerField', [], {'null': 'True', 'blank': 'True'}),
+            'timezone': ('django.db.models.fields.CharField', [], {'default': "'America/Los_Angeles'", 'max_length': '24'}),
+            'workflow': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['oozie.Workflow']", 'null': 'True'})
+        },
+        'oozie.datainput': {
+            'Meta': {'object_name': 'DataInput'},
+            'coordinator': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['oozie.Coordinator']"}),
+            'dataset': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['oozie.Dataset']", 'unique': 'True'}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'name': ('django.db.models.fields.CharField', [], {'max_length': '40'})
+        },
+        'oozie.dataoutput': {
+            'Meta': {'object_name': 'DataOutput'},
+            'coordinator': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['oozie.Coordinator']"}),
+            'dataset': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['oozie.Dataset']", 'unique': 'True'}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'name': ('django.db.models.fields.CharField', [], {'max_length': '40'})
+        },
+        'oozie.dataset': {
+            'Meta': {'object_name': 'Dataset'},
+            'advanced_end_instance': ('django.db.models.fields.CharField', [], {'default': "'0'", 'max_length': '128', 'blank': 'True'}),
+            'advanced_start_instance': ('django.db.models.fields.CharField', [], {'default': "'0'", 'max_length': '128'}),
+            'coordinator': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['oozie.Coordinator']"}),
+            'description': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '1024', 'blank': 'True'}),
+            'done_flag': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '64', 'blank': 'True'}),
+            'frequency_number': ('django.db.models.fields.SmallIntegerField', [], {'default': '1'}),
+            'frequency_unit': ('django.db.models.fields.CharField', [], {'default': "'days'", 'max_length': '20'}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'instance_choice': ('django.db.models.fields.CharField', [], {'default': "'default'", 'max_length': '10'}),
+            'name': ('django.db.models.fields.CharField', [], {'max_length': '40'}),
+            'start': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime(2013, 2, 13, 22, 26, 34, 624810)'}),
+            'timezone': ('django.db.models.fields.CharField', [], {'default': "'America/Los_Angeles'", 'max_length': '24'}),
+            'uri': ('django.db.models.fields.CharField', [], {'default': "'/data/${YEAR}${MONTH}${DAY}'", 'max_length': '1024'})
+        },
+        'oozie.decision': {
+            'Meta': {'object_name': 'Decision'},
+            'node_ptr': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['oozie.Node']", 'unique': 'True', 'primary_key': 'True'})
+        },
+        'oozie.decisionend': {
+            'Meta': {'object_name': 'DecisionEnd'},
+            'node_ptr': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['oozie.Node']", 'unique': 'True', 'primary_key': 'True'})
+        },
+        'oozie.distcp': {
+            'Meta': {'object_name': 'DistCp'},
+            'job_properties': ('django.db.models.fields.TextField', [], {'default': "'[]'"}),
+            'job_xml': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '512', 'blank': 'True'}),
+            'node_ptr': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['oozie.Node']", 'unique': 'True', 'primary_key': 'True'}),
+            'params': ('django.db.models.fields.TextField', [], {'default': "'[]'"}),
+            'prepares': ('django.db.models.fields.TextField', [], {'default': "'[]'"})
+        },
+        'oozie.email': {
+            'Meta': {'object_name': 'Email'},
+            'body': ('django.db.models.fields.TextField', [], {'default': "''"}),
+            'cc': ('django.db.models.fields.TextField', [], {'default': "''", 'blank': 'True'}),
+            'node_ptr': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['oozie.Node']", 'unique': 'True', 'primary_key': 'True'}),
+            'subject': ('django.db.models.fields.TextField', [], {'default': "''"}),
+            'to': ('django.db.models.fields.TextField', [], {'default': "''"})
+        },
+        'oozie.end': {
+            'Meta': {'object_name': 'End'},
+            'node_ptr': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['oozie.Node']", 'unique': 'True', 'primary_key': 'True'})
+        },
+        'oozie.fork': {
+            'Meta': {'object_name': 'Fork'},
+            'node_ptr': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['oozie.Node']", 'unique': 'True', 'primary_key': 'True'})
+        },
+        'oozie.fs': {
+            'Meta': {'object_name': 'Fs'},
+            'chmods': ('django.db.models.fields.TextField', [], {'default': "'[]'", 'blank': 'True'}),
+            'deletes': ('django.db.models.fields.TextField', [], {'default': "'[]'", 'blank': 'True'}),
+            'mkdirs': ('django.db.models.fields.TextField', [], {'default': "'[]'", 'blank': 'True'}),
+            'moves': ('django.db.models.fields.TextField', [], {'default': "'[]'", 'blank': 'True'}),
+            'node_ptr': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['oozie.Node']", 'unique': 'True', 'primary_key': 'True'}),
+            'touchzs': ('django.db.models.fields.TextField', [], {'default': "'[]'", 'blank': 'True'})
+        },
+        'oozie.generic': {
+            'Meta': {'object_name': 'Generic'},
+            'node_ptr': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['oozie.Node']", 'unique': 'True', 'primary_key': 'True'}),
+            'xml': ('django.db.models.fields.TextField', [], {'default': "''"})
+        },
+        'oozie.history': {
+            'Meta': {'object_name': 'History'},
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'job': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['oozie.Job']"}),
+            'oozie_job_id': ('django.db.models.fields.CharField', [], {'max_length': '128'}),
+            'properties': ('django.db.models.fields.TextField', [], {}),
+            'submission_date': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'db_index': 'True', 'blank': 'True'}),
+            'submitter': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"})
+        },
+        'oozie.hive': {
+            'Meta': {'object_name': 'Hive'},
+            'archives': ('django.db.models.fields.TextField', [], {'default': "'[]'"}),
+            'files': ('django.db.models.fields.TextField', [], {'default': "'[]'"}),
+            'job_properties': ('django.db.models.fields.TextField', [], {'default': '\'[{"name":"oozie.hive.defaults","value":"hive-site.xml"}]\''}),
+            'job_xml': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '512', 'blank': 'True'}),
+            'node_ptr': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['oozie.Node']", 'unique': 'True', 'primary_key': 'True'}),
+            'params': ('django.db.models.fields.TextField', [], {'default': "'[]'"}),
+            'prepares': ('django.db.models.fields.TextField', [], {'default': "'[]'"}),
+            'script_path': ('django.db.models.fields.CharField', [], {'max_length': '256'})
+        },
+        'oozie.java': {
+            'Meta': {'object_name': 'Java'},
+            'archives': ('django.db.models.fields.TextField', [], {'default': "'[]'"}),
+            'args': ('django.db.models.fields.CharField', [], {'max_length': '4096', 'blank': 'True'}),
+            'files': ('django.db.models.fields.TextField', [], {'default': "'[]'"}),
+            'jar_path': ('django.db.models.fields.CharField', [], {'max_length': '512'}),
+            'java_opts': ('django.db.models.fields.CharField', [], {'max_length': '256', 'blank': 'True'}),
+            'job_properties': ('django.db.models.fields.TextField', [], {'default': "'[]'"}),
+            'job_xml': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '512', 'blank': 'True'}),
+            'main_class': ('django.db.models.fields.CharField', [], {'max_length': '256'}),
+            'node_ptr': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['oozie.Node']", 'unique': 'True', 'primary_key': 'True'}),
+            'prepares': ('django.db.models.fields.TextField', [], {'default': "'[]'"})
+        },
+        'oozie.job': {
+            'Meta': {'object_name': 'Job'},
+            'deployment_dir': ('django.db.models.fields.CharField', [], {'max_length': '1024', 'blank': 'True'}),
+            'description': ('django.db.models.fields.CharField', [], {'max_length': '1024', 'blank': 'True'}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'is_shared': ('django.db.models.fields.BooleanField', [], {'default': 'False', 'db_index': 'True', 'blank': 'True'}),
+            'last_modified': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'db_index': 'True', 'blank': 'True'}),
+            'name': ('django.db.models.fields.CharField', [], {'max_length': '40'}),
+            'owner': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}),
+            'parameters': ('django.db.models.fields.TextField', [], {'default': '\'[{"name":"oozie.use.system.libpath","value":"true"}]\''}),
+            'schema_version': ('django.db.models.fields.CharField', [], {'max_length': '128'})
+        },
+        'oozie.join': {
+            'Meta': {'object_name': 'Join'},
+            'node_ptr': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['oozie.Node']", 'unique': 'True', 'primary_key': 'True'})
+        },
+        'oozie.kill': {
+            'Meta': {'object_name': 'Kill'},
+            'message': ('django.db.models.fields.CharField', [], {'default': "'Action failed, error message[${wf:errorMessage(wf:lastErrorNode())}]'", 'max_length': '256'}),
+            'node_ptr': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['oozie.Node']", 'unique': 'True', 'primary_key': 'True'})
+        },
+        'oozie.link': {
+            'Meta': {'object_name': 'Link'},
+            'child': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'parent_node'", 'to': "orm['oozie.Node']"}),
+            'comment': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '1024', 'blank': 'True'}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'name': ('django.db.models.fields.CharField', [], {'max_length': '40'}),
+            'parent': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'child_node'", 'to': "orm['oozie.Node']"})
+        },
+        'oozie.mapreduce': {
+            'Meta': {'object_name': 'Mapreduce'},
+            'archives': ('django.db.models.fields.TextField', [], {'default': "'[]'"}),
+            'files': ('django.db.models.fields.TextField', [], {'default': "'[]'"}),
+            'jar_path': ('django.db.models.fields.CharField', [], {'max_length': '512'}),
+            'job_properties': ('django.db.models.fields.TextField', [], {'default': "'[]'"}),
+            'job_xml': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '512', 'blank': 'True'}),
+            'node_ptr': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['oozie.Node']", 'unique': 'True'}),
+            'prepares': ('django.db.models.fields.TextField', [], {'default': "'[]'"})
+        },
+        'oozie.node': {
+            'Meta': {'object_name': 'Node'},
+            'children': ('django.db.models.fields.related.ManyToManyField', [], {'related_name': "'parents'", 'symmetrical': 'False', 'through': "orm['oozie.Link']", 'to': "orm['oozie.Node']"}),
+            'description': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '1024', 'blank': 'True'}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'name': ('django.db.models.fields.CharField', [], {'max_length': '40'}),
+            'node_type': ('django.db.models.fields.CharField', [], {'max_length': '64'}),
+            'workflow': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['oozie.Workflow']"})
+        },
+        'oozie.pig': {
+            'Meta': {'object_name': 'Pig'},
+            'archives': ('django.db.models.fields.TextField', [], {'default': "'[]'"}),
+            'files': ('django.db.models.fields.TextField', [], {'default': "'[]'"}),
+            'job_properties': ('django.db.models.fields.TextField', [], {'default': "'[]'"}),
+            'job_xml': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '512', 'blank': 'True'}),
+            'node_ptr': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['oozie.Node']", 'unique': 'True', 'primary_key': 'True'}),
+            'params': ('django.db.models.fields.TextField', [], {'default': "'[]'"}),
+            'prepares': ('django.db.models.fields.TextField', [], {'default': "'[]'"}),
+            'script_path': ('django.db.models.fields.CharField', [], {'max_length': '256'})
+        },
+        'oozie.shell': {
+            'Meta': {'object_name': 'Shell'},
+            'archives': ('django.db.models.fields.TextField', [], {'default': "'[]'"}),
+            'capture_output': ('django.db.models.fields.BooleanField', [], {'default': 'False', 'blank': 'True'}),
+            'command': ('django.db.models.fields.CharField', [], {'max_length': '256'}),
+            'files': ('django.db.models.fields.TextField', [], {'default': "'[]'"}),
+            'job_properties': ('django.db.models.fields.TextField', [], {'default': "'[]'"}),
+            'job_xml': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '512', 'blank': 'True'}),
+            'node_ptr': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['oozie.Node']", 'unique': 'True', 'primary_key': 'True'}),
+            'params': ('django.db.models.fields.TextField', [], {'default': "'[]'"}),
+            'prepares': ('django.db.models.fields.TextField', [], {'default': "'[]'"})
+        },
+        'oozie.sqoop': {
+            'Meta': {'object_name': 'Sqoop'},
+            'archives': ('django.db.models.fields.TextField', [], {'default': "'[]'"}),
+            'files': ('django.db.models.fields.TextField', [], {'default': "'[]'"}),
+            'job_properties': ('django.db.models.fields.TextField', [], {'default': "'[]'"}),
+            'job_xml': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '512', 'blank': 'True'}),
+            'node_ptr': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['oozie.Node']", 'unique': 'True', 'primary_key': 'True'}),
+            'params': ('django.db.models.fields.TextField', [], {'default': "'[]'"}),
+            'prepares': ('django.db.models.fields.TextField', [], {'default': "'[]'"}),
+            'script_path': ('django.db.models.fields.TextField', [], {'default': "''", 'blank': 'True'})
+        },
+        'oozie.ssh': {
+            'Meta': {'object_name': 'Ssh'},
+            'capture_output': ('django.db.models.fields.BooleanField', [], {'default': 'False', 'blank': 'True'}),
+            'command': ('django.db.models.fields.CharField', [], {'max_length': '256'}),
+            'host': ('django.db.models.fields.CharField', [], {'max_length': '256'}),
+            'node_ptr': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['oozie.Node']", 'unique': 'True', 'primary_key': 'True'}),
+            'params': ('django.db.models.fields.TextField', [], {'default': "'[]'"}),
+            'user': ('django.db.models.fields.CharField', [], {'max_length': '64'})
+        },
+        'oozie.start': {
+            'Meta': {'object_name': 'Start'},
+            'node_ptr': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['oozie.Node']", 'unique': 'True'})
+        },
+        'oozie.streaming': {
+            'Meta': {'object_name': 'Streaming'},
+            'archives': ('django.db.models.fields.TextField', [], {'default': "'[]'"}),
+            'files': ('django.db.models.fields.TextField', [], {'default': "'[]'"}),
+            'job_properties': ('django.db.models.fields.TextField', [], {'default': "'[]'"}),
+            'mapper': ('django.db.models.fields.CharField', [], {'max_length': '512'}),
+            'node_ptr': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['oozie.Node']", 'unique': 'True', 'primary_key': 'True'}),
+            'reducer': ('django.db.models.fields.CharField', [], {'max_length': '512'})
+        },
+        'oozie.subworkflow': {
+            'Meta': {'object_name': 'SubWorkflow'},
+            'job_properties': ('django.db.models.fields.TextField', [], {'default': "'[]'"}),
+            'node_ptr': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['oozie.Node']", 'unique': 'True', 'primary_key': 'True'}),
+            'propagate_configuration': ('django.db.models.fields.BooleanField', [], {'default': 'True', 'blank': 'True'}),
+            'sub_workflow': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['oozie.Workflow']"})
+        },
+        'oozie.workflow': {
+            'Meta': {'object_name': 'Workflow', '_ormbases': ['oozie.Job']},
+            'end': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'end_workflow'", 'null': 'True', 'to': "orm['oozie.End']"}),
+            'is_single': ('django.db.models.fields.BooleanField', [], {'default': 'False', 'blank': 'True'}),
+            'job_properties': ('django.db.models.fields.TextField', [], {'default': "'[]'"}),
+            'job_ptr': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['oozie.Job']", 'unique': 'True', 'primary_key': 'True'}),
+            'job_xml': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '512', 'blank': 'True'}),
+            'start': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'start_workflow'", 'null': 'True', 'to': "orm['oozie.Start']"})
+        }
+    }
+    
+    complete_apps = ['oozie']

+ 86 - 0
apps/oozie/src/oozie/models.py

@@ -139,6 +139,10 @@ class Job(models.Model):
       return self.coordinator
     except Coordinator.DoesNotExist:
       pass
+    try:
+      return self.bundle
+    except Bundle.DoesNotExist:
+      pass
 
   def get_type(self):
     return self.get_full_node().get_type()
@@ -1419,6 +1423,86 @@ class DataOutput(models.Model):
   unique_together = ('coordinator', 'name')
 
 
+class BundledCoordinator(models.Model):
+  bundle = models.ForeignKey('Bundle', verbose_name=_t('Bundle'),
+                             help_text=_t('The bundle regrouping all the coordinators.'))
+  coordinator = models.ForeignKey(Coordinator, verbose_name=_t('Coordinator'),
+                                  help_text=_t('The coordinator to batch with other coordinators.'))
+
+  parameters = models.TextField(default='[{"name":"oozie.use.system.libpath","value":"true"}]', verbose_name=_t('Oozie parameters'),
+                                help_text=_t('Constants used at the submission time (e.g. market=US, oozie.use.system.libpath=true).'))
+
+  def get_parameters(self):
+    return json.loads(self.parameters)
+
+
+class Bundle(Job):
+  """
+  http://oozie.apache.org/docs/3.3.0/BundleFunctionalSpec.html
+  """
+  kick_off_time = models.DateTimeField(default=datetime.today(), verbose_name=_t('Start'),
+                                       help_text=_t('When to start the first coordinators.'))
+  coordinators = models.ManyToManyField(Coordinator, through='BundledCoordinator')
+
+  HUE_ID = 'hue-id-b'
+
+  def get_type(self):
+    return 'bundle'
+
+  def to_xml(self, mapping=None):
+    if mapping is None:
+      mapping = {}
+    tmpl = "editor/gen/bundle.xml.mako"
+    return re.sub(re.compile('\s*\n+', re.MULTILINE), '\n', django_mako.render_to_string(tmpl, {'bundle': self, 'mapping': mapping}))
+
+  def clone(self, new_owner=None):
+    bundleds = BundledCoordinator.objects.filter(bundle=self)
+
+    copy = self
+    copy.pk = None
+    copy.id = None
+    copy.name += '-copy'
+    copy.deployment_dir = ''
+    if new_owner is not None:
+      copy.owner = new_owner
+    copy.save()
+
+    for bundled in bundleds:
+      bundled.pk = None
+      bundled.id = None
+      bundled.bundle = copy
+      bundled.save()
+
+    return copy
+
+  @classmethod
+  def get_application_path_key(cls):
+    return 'oozie.bundle.application.path'
+
+  @classmethod
+  def get_application_filename(cls):
+    return 'bundle.xml'
+
+  def get_absolute_url(self):
+    return reverse('oozie:edit_bundle', kwargs={'bundle': self.id})
+
+  def find_parameters(self):
+    params = {}
+
+    for bundled in self.coordinators.all():
+      for param in bundled.coordinator.find_parameters():
+        params[param] = ''
+
+      for param in find_parameters(bundled, ['parameters']):
+        params[param] = ''
+
+    return params
+
+  @property
+  def kick_off_time_utc(self):
+    return utc_datetime_format(self.kick_off_time)
+
+
 class HistoryManager(models.Manager):
   def create_from_submission(self, submission):
     History.objects.create(submitter=submission.user,
@@ -1448,6 +1532,8 @@ class History(models.Model):
 
     if self.oozie_job_id.endswith('C'):
       view = 'oozie:list_oozie_coordinator'
+    elif self.oozie_job_id.endswith('B'):
+      view = 'oozie:list_oozie_bundle'
 
     return reverse(view, kwargs={'job_id': self.oozie_job_id})
 

+ 472 - 0
apps/oozie/src/oozie/templates/dashboard/list_oozie_bundle.mako

@@ -0,0 +1,472 @@
+## Licensed to Cloudera, Inc. under one
+## or more contributor license agreements.  See the NOTICE file
+## distributed with this work for additional information
+## regarding copyright ownership.  Cloudera, Inc. licenses this file
+## to you under the Apache License, Version 2.0 (the
+## "License"); you may not use this file except in compliance
+## with the License.  You may obtain a copy of the License at
+##
+##     http://www.apache.org/licenses/LICENSE-2.0
+##
+## Unless required by applicable law or agreed to in writing, software
+## distributed under the License is distributed on an "AS IS" BASIS,
+## WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+## See the License for the specific language governing permissions and
+## limitations under the License.
+
+<%!
+  from desktop.views import commonheader, commonfooter
+  from django.utils.translation import ugettext as _
+%>
+
+<%namespace name="layout" file="../navigation-bar.mako" />
+<%namespace name="utils" file="../utils.inc.mako" />
+
+${ commonheader(_("Oozie App"), "oozie", user, "100px") | n,unicode }
+${ layout.menubar(section='dashboard') }
+
+
+<div class="container-fluid">
+  ${ layout.dashboard_sub_menubar(section='bundles') }
+
+  <h1>${ _('Bundle') } ${ oozie_bundle.appName }</h1>
+
+
+<div class="row-fluid">
+  <div class="span2">
+    <div class="well sidebar-nav">
+      <ul class="nav nav-list">
+        <li class="nav-header">${ _('Bundle') }</li>
+        <li>
+            % if bundle is not None:
+              <a href="${ bundle.get_absolute_url() }">${ oozie_bundle.appName }</a>
+            % else:
+              ${ oozie_bundle.appName }
+            % endif
+        </li>
+
+        <li class="nav-header">${ _('Submitter') }</li>
+        <li>${ oozie_bundle.user }</li>
+
+        <li class="nav-header">${ _('Status') }</li>
+        <li id="status"><span class="label ${ utils.get_status(oozie_bundle.status) }">${ oozie_bundle.status }</span></li>
+
+        <li class="nav-header">${ _('Progress') }</li>
+        <li id="progress">
+          <div class="progress">
+            <div class="bar" style="width: 0">${ oozie_bundle.get_progress() }%</div>
+          </div>
+        </li>
+
+        <li class="nav-header">${ _('Kick off time') }</li>
+        <li>${ oozie_bundle.kickoffTime }</li>
+
+        <li class="nav-header">${ _('Created time') }</li>
+        <li id="endTime">${ oozie_bundle.createdTime }</li>
+
+        % if bundle:
+            <li class="nav-header">${ _('Bundles') }</li>
+          % for bundled in bundle.coordinators.all():
+            <li rel="tooltip" title="${ bundled.coordinator.name }">
+              <i class="icon-eye-open"></i> <span class="dataset">${ bundled.coordinator.name }</span>
+            </li>
+          % endfor
+        % endif
+
+        % if has_job_edition_permission(oozie_bundle, user):
+          <li class="nav-header">${ _('Manage') }</li>
+          <li>
+            <button title="${_('Kill %(bundle)s') % dict(bundle=oozie_bundle.id)}"
+              id="kill-btn"
+              class="btn btn-small confirmationModal
+               % if not oozie_bundle.is_running():
+                 hide
+               % endif
+              "
+              alt="${ _('Are you sure you want to kill bundle %s?') % oozie_bundle.id }"
+              href="javascript:void(0)"
+              data-url="${ url('oozie:manage_oozie_jobs', job_id=oozie_bundle.id, action='kill') }"
+              data-message="${ _('The bundle was killed!') }"
+              data-confirmation-message="${ _('Are you sure you\'d like to kill this job?') }">
+                ${_('Kill')}
+            </button>
+            <div id="rerun-coord-modal" class="modal hide"></div>
+            <button title="${ _('Suspend the bundle after finishing the current running actions') }" id="suspend-btn"
+               data-url="${ url('oozie:manage_oozie_jobs', job_id=oozie_bundle.id, action='suspend') }"
+               data-confirmation-message="${ _('Are you sure you\'d like to suspend this job?') }"
+               class="btn btn-small confirmationModal
+               % if not oozie_bundle.is_running():
+                 hide
+               % endif
+               " rel="tooltip" data-placement="right">
+              ${ _('Suspend') }
+            </button>
+            <button title="${ _('Resume the bundle') }" id="resume-btn"
+               data-url="${ url('oozie:manage_oozie_jobs', job_id=oozie_bundle.id, action='resume') }"
+               data-confirmation-message="${ _('Are you sure you\'d like to resume this job?') }"
+               class="btn btn-small confirmationModal
+               % if oozie_bundle.is_running():
+                 hide
+               % endif
+               ">
+              ${ _('Resume') }
+            </button>
+          </li>
+        % endif
+      </ul>
+    </div>
+  </div>
+  <div class="span10">
+    <ul class="nav nav-tabs">
+      <li class="active"><a href="#calendar" data-toggle="tab">${ _('Coordinators') }</a></li>
+      <li><a href="#actions" data-toggle="tab">${ _('Actions') }</a></li>
+      <li><a href="#configuration" data-toggle="tab">${ _('Configuration') }</a></li>
+      <li><a href="#log" data-toggle="tab">${ _('Log') }</a></li>
+      <li><a href="#definition" data-toggle="tab">${ _('Definition') }</a></li>
+    </ul>
+
+    <div class="tab-content" style="padding-bottom:200px">
+      <div class="tab-pane active" id="calendar">
+        <table class="table table-striped table-condensed">
+          <thead>
+          <tr>
+            <th>${ _('Day') }</th>
+            <th>${ _('Comment') }</th>
+          </tr>
+          </thead>
+          <tbody data-bind="template: {name: 'calendarTemplate', foreach: actions}">
+          </tbody>
+          <tfoot>
+            <tr data-bind="visible: isLoading()">
+              <td colspan="2" class="left">
+                <img src="/static/art/spinner.gif" />
+              </td>
+            </tr>
+            <tr data-bind="visible: actions().length == 0 && !isLoading()">
+              <td colspan="2">
+                <div class="alert">
+                  ${ _('There are no actions to be shown.') }
+                </div>
+              </td>
+            </tr>
+          </tfoot>
+        </table>
+      </div>
+
+      <script id="calendarTemplate" type="text/html">
+        <tr>
+          <td>
+            <a data-bind="attr: {href: url}" data-row-selector="true">
+              <span data-bind="text: name, attr: {'class': statusClass, 'id': 'date-' + $index()}"></span>
+            </a>
+          </td>
+          <td><span data-bind="text: lastAction"></span></td>
+        </tr>
+      </script>
+
+
+      <div class="tab-pane" id="actions">
+        <table class="table table-striped table-condensed" cellpadding="0" cellspacing="0">
+          <thead>
+          <tr>
+            <th>${ _('Name') }</th>
+            <th>${ _('Id') }</th>
+
+            <th>${ _('Last action') }</th>
+            <th>${ _('Acl') }</th>
+
+            <th>${ _('Type') }</th>
+            <th>${ _('Status') }</th>
+
+            <th>${ _('Time out') }</th>
+            <th>${ _('Start Time') }</th>
+
+            <th>${ _('End Time') }</th>
+            <th>${ _('Pause Time') }</th>
+          </tr>
+          </thead>
+
+          <tbody data-bind="template: {name: 'actionTemplate', foreach: actions}">
+          </tbody>
+
+          <tfoot>
+          <tr data-bind="visible: isLoading()">
+            <td colspan="10" class="left">
+              <img src="/static/art/spinner.gif" />
+            </td>
+          </tr>
+          <tr data-bind="visible: !isLoading() && actions().length == 0">
+            <td colspan="10">
+              <div class="alert">
+                ${ _('There are no actions to be shown.') }
+              </div>
+            </td>
+          </tr>
+          </tfoot>
+        </table>
+      </div>
+
+      <script id="actionTemplate" type="text/html">
+        <tr>
+          <td>
+            <a data-bind="visible:name !='', attr: {href: url}, text: name"></a>
+          </td>
+          <td>
+            <a data-bind="visible:externalId !='', attr: {href: url}, text: id" data-row-selector"true"></a>
+          </td>
+          <td data-bind="text: lastAction"></td>
+          <td data-bind="text: acl"></td>
+          <td data-bind="text: type"></td>
+          <td><span data-bind="text: status, attr: {'class': statusClass}"></span></td>
+          <td data-bind="text: timeOut"></td>
+          <td data-bind="text: startTime"></td>
+          <td data-bind="text: endTime"></td>
+          <td data-bind="text: pauseTime"></td>
+        </tr>
+      </script>
+
+      <div class="tab-pane" id="configuration">
+        ${ utils.display_conf(oozie_bundle.conf_dict) }
+      </div>
+
+      <div class="tab-pane" id="log">
+        <pre>${ oozie_bundle.log }</pre>
+      </div>
+
+      <div class="tab-pane" id="definition">
+        <textarea id="definitionEditor">${ oozie_bundle.definition }</textarea>
+      </div>
+    </div>
+
+    <div style="margin-bottom: 16px">
+      <a href="${ url('oozie:list_oozie_bundles') }" class="btn">${ _('Back') }</a>
+    </div>
+
+  </div>
+</div>
+
+
+
+
+
+
+</div>
+
+<div id="confirmation" class="modal hide">
+  <div class="modal-header">
+    <a href="#" class="close" data-dismiss="modal">&times;</a>
+    <h3 class="message"></h3>
+  </div>
+  <div class="modal-footer">
+    <a href="#" class="btn" data-dismiss="modal">${_('No')}</a>
+    <a class="btn btn-danger" href="javascript:void(0);">${_('Yes')}</a>
+  </div>
+</div>
+
+<script src="/oozie/static/js/utils.js" type="text/javascript" charset="utf-8"></script>
+<script src="/static/ext/js/knockout-2.1.0.js" type="text/javascript" charset="utf-8"></script>
+<script src="/static/ext/js/codemirror-3.0.js"></script>
+<link rel="stylesheet" href="/static/ext/css/codemirror.css">
+<script src="/static/ext/js/codemirror-xml.js"></script>
+
+<style>
+  .CodeMirror.cm-s-default {
+    height:500px;
+  }
+  .sidebar-nav {
+    padding: 9px 0;
+  }
+</style>
+
+<script>
+
+  var Action = function (action) {
+    return {
+      id: action.id,
+      url: action.url,
+      name: action.name,
+      type: action.type,
+      status: action.status,
+      statusClass: "label " + getStatusClass(action.status),
+      externalId: action.externalId,
+      frequency: action.frequency,
+      concurrency: action.concurrency,
+      pauseTime: action.pauseTime,
+      acl: action.acl,
+      user: action.user,
+      timeOut: action.timeOut,
+      coordJobPath: action.coordJobPath,
+      executionPolicy: action.executionPolicy,
+      startTime: action.startTime,
+      endTime: action.endTime,
+      lastAction: action.lastAction
+    }
+  }
+
+  var RunningCoordinatorActionsModel = function (actions) {
+    var self = this;
+    self.isLoading = ko.observable(true);
+    self.actions = ko.observableArray(ko.utils.arrayMap(actions), function (action) {
+      return new Action(action);
+    });
+  };
+
+  var viewModel = new RunningCoordinatorActionsModel([]);
+  ko.applyBindings(viewModel);
+
+  $(document).ready(function(){
+    $("a[data-row-selector='true']").jHueRowSelector();
+
+    $("*[rel=tooltip]").tooltip();
+
+    $(".dataset").each(function () {
+      if ($(this).text().length > 15) {
+        $(this).html($(this).text().substr(0, 14) + "&hellip;");
+      }
+      $(this).removeClass("hide");
+    });
+
+
+    var definitionEditor = $("#definitionEditor")[0];
+
+    var codeMirror = CodeMirror(function(elt) {
+      definitionEditor.parentNode.replaceChild(elt, definitionEditor);
+      }, {
+        value: definitionEditor.value,
+      readOnly: true,
+      lineNumbers: true
+    });
+
+    // force refresh on tab change
+    $("a[data-toggle='tab']").on("shown", function (e) {
+      if ($(e.target).attr("href") == "#definition") {
+        codeMirror.refresh();
+      }
+    });
+
+    $(".confirmationModal").click(function(){
+      var _this = $(this);
+      $("#confirmation .message").text(_this.attr("data-confirmation-message"));
+      $("#confirmation").modal("show");
+      $("#confirmation a.btn-danger").click(function() {
+        _this.trigger('confirmation');
+      });
+    });
+
+    $(".confirmationModal").bind('confirmation', function() {
+      var _this = this;
+      $.post($(this).attr("data-url"),
+        { 'notification': $(this).attr("data-message") },
+        function(response) {
+          if (response['status'] != 0) {
+            $.jHueNotify.error("${ _('Problem: ') }" + response['data']);
+          } else {
+            window.location.reload();
+          }
+        }
+      );
+      return false;
+    });
+
+    $("#suspend-btn").bind('confirmation', function() {
+      var _this = this;
+      $.post($(this).data("url"),
+        { 'notification': $(this).data("message") },
+        function(response) {
+          if (response['status'] != 0) {
+            $.jHueNotify.error("${ _('Error: ') }" + response['data']);
+          } else {
+            window.location.reload();
+          }
+        }
+      );
+      return false;
+    });
+
+    $('#rerun-btn').click(function() {
+      var _action = $(this).data("rerun-url");
+
+      $.get(_action, function(response) {
+          $('#rerun-coord-modal').html(response);
+          $('#rerun-coord-modal').modal('show');
+        }
+      );
+     });
+
+    resizeLogs();
+    refreshView();
+    var logsAtEnd = true;
+
+    function refreshView() {
+      $.getJSON("${ oozie_bundle.get_absolute_url() }" + "?format=json", function (data) {
+        viewModel.isLoading(false);
+        if (data.actions){
+          viewModel.actions(ko.utils.arrayMap(data.actions, function (action) {
+            return new Action(action);
+          }));
+        }
+
+        $("#status span").attr("class", "label").addClass(getStatusClass(data.status)).text(data.status);
+
+        if (data.id && data.status != "RUNNING" && data.status != "SUSPENDED" && data.status != "KILLED" && data.status != "FAILED"){
+          $("#kill-btn").hide();
+          $("#rerun-btn").show();
+        }
+        
+        if (data.id && data.status == "KILLED") {
+          $("#kill-btn").hide();
+        }
+
+        if (data.id && (data.status == "RUNNING" || data.status == "RUNNINGWITHERROR")){
+          $("#suspend-btn").show();
+        } else {
+          $("#suspend-btn").hide();
+        }
+
+        if (data.id && (data.status == "SUSPENDED" || data.status == "SUSPENDEDWITHERROR" || data.status == "SUSPENDEDWITHERROR"
+            || data.status == "PREPSUSPENDED")){
+          $("#resume-btn").show();
+        } else {
+          $("#resume-btn").hide();
+        }
+
+        $("#progress .bar").text(data.progress+"%").css("width", data.progress+"%").attr("class", "bar "+getStatusClass(data.status, "bar-"));
+
+        var _logsEl = $("#log pre");
+        var newLines = data.log.split("\n").slice(_logsEl.text().split("\n").length);
+        _logsEl.text(_logsEl.text() + newLines.join("\n"));
+        if (logsAtEnd) {
+          _logsEl.scrollTop(_logsEl[0].scrollHeight - _logsEl.height());
+        }
+
+        if (data.status != "RUNNING" && data.status != "PREP"){
+          return;
+        }
+        window.setTimeout(refreshView, 1000);
+      });
+    }
+
+    $(window).resize(function () {
+      resizeLogs();
+    });
+
+    $("a[href='#log']").on("shown", function () {
+      resizeLogs();
+    });
+
+    $("#log pre").scroll(function () {
+      if ($(this).scrollTop() + $(this).height() + 20 >= $(this)[0].scrollHeight) {
+        logsAtEnd = true;
+      }
+      else {
+        logsAtEnd = false;
+      }
+    });
+
+    function resizeLogs() {
+      $("#log pre").css("overflow", "auto").height($(window).height() - $("#log pre").position().top - 80);
+    }
+  });
+</script>
+
+${ commonfooter(messages) | n,unicode }

+ 389 - 0
apps/oozie/src/oozie/templates/dashboard/list_oozie_bundles.mako

@@ -0,0 +1,389 @@
+## Licensed to Cloudera, Inc. under one
+## or more contributor license agreements.  See the NOTICE file
+## distributed with this work for additional information
+## regarding copyright ownership.  Cloudera, Inc. licenses this file
+## to you under the Apache License, Version 2.0 (the
+## "License"); you may not use this file except in compliance
+## with the License.  You may obtain a copy of the License at
+##
+##     http://www.apache.org/licenses/LICENSE-2.0
+##
+## Unless required by applicable law or agreed to in writing, software
+## distributed under the License is distributed on an "AS IS" BASIS,
+## WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+## See the License for the specific language governing permissions and
+## limitations under the License.
+
+<%!
+  from desktop.views import commonheader, commonfooter
+  from django.utils.translation import ugettext as _
+%>
+
+<%namespace name="layout" file="../navigation-bar.mako" />
+<%namespace name="utils" file="../utils.inc.mako" />
+
+${ commonheader(_("Oozie App"), "oozie", user, "100px") | n,unicode }
+${layout.menubar(section='dashboard')}
+
+
+<div class="container-fluid">
+  ${ layout.dashboard_sub_menubar(section='bundles') }
+
+  <div class="well hueWell">
+    <form>
+      ${ _('Filter:') }
+      <input type="text" id="filterInput" class="input-xlarge search-query" placeholder="${ _('Search for username, name, etc...') }">
+
+      <span class="pull-right">
+        <span style="padding-right:10px;float:left;margin-top:3px">
+        ${ _('Show only') }
+        </span>
+        <span class="btn-group" style="float:left">
+          <a class="btn btn-date btn-info" data-value="1">${ _('1') }</a>
+          <a class="btn btn-date btn-info" data-value="7">${ _('7') }</a>
+          <a class="btn btn-date btn-info" data-value="15">${ _('15') }</a>
+          <a class="btn btn-date btn-info" data-value="30">${ _('30') }</a>
+        </span>
+        <span style="float:left;padding-left:10px;padding-right:10px;margin-top:3px">${ _('days with status') }</span>
+        <span class="btn-group" style="float:left;">
+          <a class="btn btn-status btn-success" data-value='SUCCEEDED'>${ _('Succeeded') }</a>
+          <a class="btn btn-status btn-warning" data-value='RUNNING'>${ _('Running') }</a>
+          <a class="btn btn-status btn-danger disable-feedback" data-value='KILLED'>${ _('Killed') }</a>
+        </span>
+      </span>
+   </form>
+  </div>
+
+  <div style="min-height:200px">
+    <h1>${ _('Running') }</h1>
+    <table class="table table-condensed" id="running-table">
+      <thead>
+        <tr>
+          <th width="15%">${ _('Kickoff Time') }</th>
+          <th width="10%">${ _('Status') }</th>
+          <th width="25%">${ _('Name') }</th>
+          <th width="10%">${ _('Progress') }</th>
+          <th width="15%">${ _('Submitter') }</th>
+          <th width="15%">${ _('Id') }</th>
+          <th width="10%">${ _('Action') }</th>
+        </tr>
+      </thead>
+      <tbody>
+
+      </tbody>
+    </table>
+  </div>
+
+  <div>
+    <h1>${ _('Completed') }</h1>
+    <table class="table table-condensed" id="completed-table" data-tablescroller-disable="true">
+      <thead>
+        <tr>
+          <th width="15%">${ _('Kickoff Time') }</th>
+          <th width="10%">${ _('Status') }</th>
+          <th width="35%">${ _('Name') }</th>
+          <th width="15%">${ _('Submitter') }</th>
+          <th width="25%">${ _('Id') }</th>
+        </tr>
+      </thead>
+      <tbody>
+
+      </tbody>
+     </table>
+   </div>
+</div>
+
+
+<div id="confirmation" class="modal hide">
+  <div class="modal-header">
+    <a href="#" class="close" data-dismiss="modal">&times;</a>
+    <h3 class="message"></h3>
+  </div>
+  <div class="modal-footer">
+    <a href="#" class="btn" data-dismiss="modal">${_('No')}</a>
+    <a class="btn btn-danger" href="javascript:void(0);">${_('Yes')}</a>
+  </div>
+</div>
+
+<script src="/oozie/static/js/utils.js" type="text/javascript" charset="utf-8"></script>
+<script src="/static/ext/js/datatables-paging-0.1.js" type="text/javascript" charset="utf-8"></script>
+
+<script type="text/javascript" charset="utf-8">
+  var Bundle = function (bundle) {
+    return {
+      id: bundle.id,
+      endTime: bundle.endTime,
+      status: bundle.status,
+      statusClass: "label " + getStatusClass(bundle.status),
+      isRunning: bundle.isRunning,
+      kickoffTime: bundle.kickoffTime,
+      timeOut: bundle.timeOut,
+      appName: decodeURIComponent(bundle.appName),
+      progress: bundle.progress,
+      progressClass: "bar " + getStatusClass(bundle.status, "bar-"),
+      user: bundle.user,
+      absoluteUrl: bundle.absoluteUrl,
+      canEdit: bundle.canEdit,
+      killUrl: bundle.killUrl
+    }
+  }
+
+  $(document).ready(function () {
+    var runningTable = $("#running-table").dataTable({
+      "sPaginationType":"bootstrap",
+      "iDisplayLength":50,
+      "bLengthChange":false,
+      "sDom":"<'row'r>t<'row'<'span6'i><''p>>",
+      "aoColumns":[
+        { "sType":"date" },
+        null,
+        null,
+        { "sSortDataType":"dom-sort-value", "sType":"numeric" },
+        null,
+        null,
+        { "bSortable":false }
+      ],
+      "aaSorting":[
+        [ 5, "desc" ]
+      ],
+      "oLanguage":{
+        "sEmptyTable":"${_('No data available')}",
+        "sInfo":"${_('Showing _START_ to _END_ of _TOTAL_ entries')}",
+        "sInfoEmpty":"${_('Showing 0 to 0 of 0 entries')}",
+        "sInfoFiltered":"${_('(filtered from _MAX_ total entries)')}",
+        "sZeroRecords":"${_('No matching records')}",
+        "oPaginate":{
+          "sFirst":"${_('First')}",
+          "sLast":"${_('Last')}",
+          "sNext":"${_('Next')}",
+          "sPrevious":"${_('Previous')}"
+        }
+      },
+      "fnDrawCallback":function (oSettings) {
+        $("a[data-row-selector='true']").jHueRowSelector();
+      }
+    });
+
+    var completedTable = $("#completed-table").dataTable({
+      "sPaginationType":"bootstrap",
+      "iDisplayLength":50,
+      "bLengthChange":false,
+      "sDom":"<'row'r>t<'row'<'span6'i><''p>>",
+      "aoColumns":[
+        { "sType":"date" },
+        null,
+        null,
+        null,
+        null
+      ],
+      "aaSorting":[
+        [ 4, "desc" ]
+      ],
+      "oLanguage":{
+        "sEmptyTable":"${_('No data available')}",
+        "sInfo":"${_('Showing _START_ to _END_ of _TOTAL_ entries')}",
+        "sInfoEmpty":"${_('Showing 0 to 0 of 0 entries')}",
+        "sInfoFiltered":"${_('(filtered from _MAX_ total entries)')}",
+        "sZeroRecords":"${_('No matching records')}",
+        "oPaginate":{
+          "sFirst":"${_('First')}",
+          "sLast":"${_('Last')}",
+          "sNext":"${_('Next')}",
+          "sPrevious":"${_('Previous')}"
+        }
+      },
+      "fnDrawCallback":function (oSettings) {
+        $("a[data-row-selector='true']").jHueRowSelector();
+      }
+    });
+
+    $("#filterInput").keydown(function (e) {
+      if (e.which == 13) {
+        e.preventDefault();
+        return false;
+      }
+    });
+
+    $("#filterInput").keyup(function () {
+      runningTable.fnFilter($(this).val());
+      completedTable.fnFilter($(this).val());
+
+      var hash = "#";
+      if ($("a.btn-date.active").length > 0) {
+        hash += "date=" + $("a.btn-date.active").text();
+      }
+      window.location.hash = hash;
+    });
+
+
+    $("a.btn-status").click(function () {
+      $(this).toggleClass("active");
+      drawTable();
+    });
+
+    $("a.btn-date").click(function () {
+      $("a.btn-date").not(this).removeClass("active");
+      $(this).toggleClass("active");
+      drawTable();
+    });
+
+    var hash = window.location.hash;
+    if (hash != "" && hash.indexOf("=") > -1) {
+      $("a.btn-date[data-value='" + hash.split("=")[1] + "']").click();
+    }
+
+    function drawTable() {
+      runningTable.fnDraw();
+      completedTable.fnDraw();
+
+      var hash = "#";
+      if ($("a.btn-date.active").length > 0) {
+        hash += "date=" + $("a.btn-date.active").text();
+      }
+      window.location.hash = hash;
+    }
+
+    $.fn.dataTableExt.afnFiltering.push(
+      function (oSettings, aData, iDataIndex) {
+        var urlHashes = ""
+
+        var statusBtn = $("a.btn-status.active");
+        var statusFilter = true;
+        if (statusBtn.length > 0) {
+          var statuses = []
+          $.each(statusBtn, function () {
+            statuses.push($(this).attr("data-value"));
+          });
+          statusFilter = aData[1].match(RegExp(statuses.join('|'), "i")) != null;
+        }
+
+        var dateBtn = $("a.btn-date.active");
+        var dateFilter = true;
+        if (dateBtn.length > 0) {
+          var minAge = new Date() - parseInt(dateBtn.attr("data-value")) * 1000 * 60 * 60 * 24;
+          dateFilter = Date.parse(aData[0]) >= minAge;
+        }
+
+        return statusFilter && dateFilter;
+      }
+    );
+
+    $(document).on("click", ".confirmationModal", function () {
+      var _this = $(this);
+      _this.bind("confirmation", function () {
+        var _this = this;
+        $.post($(this).attr("data-url"),
+                { "notification":$(this).attr("data-message") },
+                function (response) {
+                  if (response["status"] != 0) {
+                    $.jHueNotify.error("${ _('Problem: ') }" + response["data"]);
+                  } else {
+                    window.location.reload();
+                  }
+                }
+        );
+        return false;
+      });
+      $("#confirmation .message").text(_this.attr("data-confirmation-message"));
+      $("#confirmation").modal("show");
+      $("#confirmation a.btn-danger").click(function () {
+        _this.trigger("confirmation");
+      });
+    });
+
+    refreshRunning();
+    refreshCompleted();
+
+    var numRunning = 0;
+
+    function refreshRunning() {
+      $.getJSON(window.location.pathname + "?format=json&type=running", function (data) {
+        if (data) {
+          var nNodes = runningTable.fnGetNodes();
+
+          // check for zombie nodes
+          $(nNodes).each(function (iNode, node) {
+            var nodeFound = false;
+            $(data).each(function (iBundle, currentItem) {
+              if ($(node).children("td").eq(5).text() == currentItem.id) {
+                nodeFound = true;
+              }
+            });
+            if (!nodeFound) {
+              runningTable.fnDeleteRow(node);
+              runningTable.fnDraw();
+            }
+          });
+
+          $(data).each(function (iBundle, item) {
+            var bundle = new Bundle(item);
+            var foundRow = null;
+            $(nNodes).each(function (iNode, node) {
+              if ($(node).children("td").eq(5).text() == bundle.id) {
+                foundRow = node;
+              }
+            });
+            if (foundRow == null) {
+              var killCell = "";
+              if (bundle.canEdit) {
+                killCell = '<a class="btn btn-small confirmationModal" ' +
+                        'href="javascript:void(0)" ' +
+                        'data-url="' + bundle.killUrl + '" ' +
+                        'title="${ _('Kill') } ' + bundle.id + '"' +
+                        'alt="${ _('Are you sure you want to kill bundle ')}' + bundle.id + '?" ' +
+                        'data-message="${ _('The bundle was killed!') }" ' +
+                        'data-confirmation-message="${ _('Are you sure you\'d like to kill this job?') }"' +
+                        '>${ _('Kill') }</a>';
+              }
+              if (['RUNNING', 'PREP', 'WAITING', 'SUSPENDED', 'PREPSUSPENDED', 'PREPPAUSED', 'PAUSED'].indexOf(bundle.status) > -1) {
+                runningTable.fnAddData([
+                    bundle.kickoffTime,
+                    '<span class="' + bundle.statusClass + '">' + bundle.status + '</span>',
+                    bundle.appName,
+                    '<div class="progress"><div class="' + bundle.progressClass + '" style="width:' + bundle.progress + '%">' + bundle.progress + '%</div></div>',
+                    bundle.user,
+                    '<a href="' + bundle.absoluteUrl + '" data-row-selector="true">' + bundle.id + '</a>',
+                    killCell]);
+              }
+
+            }
+            else {
+              runningTable.fnUpdate('<span class="' + bundle.statusClass + '">' + bundle.status + '</span>', foundRow, 1, false);
+              runningTable.fnUpdate('<div class="progress"><div class="' + bundle.progressClass + '" style="width:' + bundle.progress + '%">' + bundle.progress + '%</div></div>', foundRow, 3, false);
+            }
+          });
+        }
+        if (data.length == 0) {
+          runningTable.fnClearTable();
+        }
+
+        if (data.length != numRunning) {
+          refreshCompleted();
+        }
+        numRunning = data.length;
+
+        window.setTimeout(refreshRunning, 1000);
+      });
+    }
+
+    function refreshCompleted() {
+      $.getJSON(window.location.pathname + "?format=json&type=completed", function (data) {
+        completedTable.fnClearTable();
+        $(data).each(function (iWf, item) {
+          var bundle = new Bundle(item);
+          completedTable.fnAddData([
+              bundle.kickoffTime,
+              '<span class="' + bundle.statusClass + '">' + bundle.status + '</span>',
+              bundle.appName,
+              bundle.user,
+              '<a href="' + bundle.absoluteUrl + '" data-row-selector="true">' + bundle.id + '</a>'
+          ], false);
+        });
+        completedTable.fnDraw();
+      });
+    }
+
+  });
+</script>
+
+${ commonfooter(messages) | n,unicode }

+ 8 - 3
apps/oozie/src/oozie/templates/dashboard/list_oozie_coordinator.mako

@@ -29,7 +29,12 @@ ${ layout.menubar(section='dashboard') }
 <div class="container-fluid">
   ${ layout.dashboard_sub_menubar(section='coordinators') }
 
-  <h1>${ _('Coordinator') } ${ oozie_coordinator.appName }</h1>
+  <h1>
+    % if oozie_bundle:
+      ${ _('Bundle') } <a href="${ oozie_bundle.get_absolute_url() }">${ oozie_bundle.appName }</a> :
+    % endif
+    ${ _('Coordinator') } ${ oozie_coordinator.appName }
+  </h1>
 
 
 <div class="row-fluid">
@@ -408,7 +413,7 @@ ${ layout.menubar(section='dashboard') }
     var logsAtEnd = true;
 
     function refreshView() {
-      $.getJSON("${ oozie_coordinator.get_absolute_url() }" + "?format=json", function (data) {
+      $.getJSON("${ oozie_coordinator.get_absolute_url(oozie_bundle) }" + "?format=json", function (data) {
         viewModel.isLoading(false);
         if (data.actions){
           viewModel.actions(ko.utils.arrayMap(data.actions, function (action) {
@@ -444,7 +449,7 @@ ${ layout.menubar(section='dashboard') }
         if (logsAtEnd) {
           _logsEl.scrollTop(_logsEl[0].scrollHeight - _logsEl.height());
         }
-        if (data.status != "RUNNING"){
+        if (data.status != "RUNNING" && data.status != "PREP"){
           return;
         }
         window.setTimeout(refreshView, 1000);

+ 5 - 6
apps/oozie/src/oozie/templates/dashboard/list_oozie_workflow.mako

@@ -32,8 +32,11 @@ ${ layout.menubar(section='dashboard') }
   ${ layout.dashboard_sub_menubar(section='workflows') }
 
   <h1>
+    % if oozie_bundle:
+      ${ _('Bundle') } <a href="${ oozie_bundle.get_absolute_url() }">${ oozie_bundle.appName }</a> :
+    % endif
     % if oozie_coordinator:
-      ${ _('Coordinator') } <a href="${ oozie_coordinator.get_absolute_url() }">${ oozie_coordinator.appName }</a> :
+      ${ _('Coordinator') } <a href="${ oozie_coordinator.get_absolute_url(oozie_bundle) }">${ oozie_coordinator.appName }</a> :
     % endif
 
     ${ _('Workflow') } ${ oozie_workflow.appName }
@@ -198,7 +201,6 @@ ${ layout.menubar(section='dashboard') }
 
 
         <script id="actionTemplate" type="text/html">
-
           <tr>
             <td>
               <a data-bind="visible:externalId !='', attr: { href: log}" data-row-selector-exclude="true"><i class="icon-tasks"></i></a>
@@ -223,7 +225,6 @@ ${ layout.menubar(section='dashboard') }
             <td data-bind="text: data"></td>
 
           </tr>
-
         </script>
 
 
@@ -276,10 +277,8 @@ ${ layout.menubar(section='dashboard') }
       </div>
     </div>
 
-
   </div>
 
-
 </div>
 
 <div id="confirmation" class="modal hide">
@@ -483,7 +482,7 @@ ${ layout.menubar(section='dashboard') }
         if (logsAtEnd) {
           _logsEl.scrollTop(_logsEl[0].scrollHeight - _logsEl.height());
         }
-        if (data.status != "RUNNING"){
+        if (data.status != "RUNNING" && data.status != "PREP"){
           return;
         }
         window.setTimeout(refreshView, 1000);

+ 120 - 93
apps/oozie/src/oozie/templates/dashboard/list_oozie_workflow_action.mako

@@ -30,104 +30,131 @@ ${ layout.menubar(section='running') }
   ${ layout.dashboard_sub_menubar(section='workflows') }
 
   <h1>
-    ${ _('Workflow') } <a href="${ url('oozie:list_oozie_workflow', job_id=workflow.id) }">${ workflow.appName }</a> :
+    % if oozie_bundle:
+      ${ _('Bundle') } <a href="${ oozie_bundle.get_absolute_url() }">${ oozie_bundle.appName }</a> :
+    % endif
+    % if oozie_coordinator:
+      ${ _('Coordinator') } <a href="${ oozie_coordinator.get_absolute_url(oozie_bundle) }">${ oozie_coordinator.appName }</a> :
+    % endif
+    ${ _('Workflow') } <a href="${ workflow.get_absolute_url() }">${ workflow.appName }</a> :
     ${ _('Action') } ${ action.name }
   </h1>
 
-  <table class="table table-condensed datatables" id="jobTable">
-    <thead>
-      <tr>
-        <th>${ _('Property') }</th>
-        <th>${ _('Value') }</th>
-      </tr>
-    </thead>
-    <tbody>
-      <tr>
-        <td>${ _('Name') }</td>
-        <td>${ action.name }</td>
-      </tr>
-      <tr>
-        <td>${ _('Type') }</td>
-        <td>${ action.type }</td>
-      </tr>
-      <tr>
-        <td>${ _('Status') }</td>
-        <td><span class="label ${ utils.get_status(action.status) }">${ action.status }</span></td>
-      </tr>
-      <tr>
-        <td>${ _('Configuration') }</td>
-        <td>${ utils.display_conf(action.conf_dict) }</td>
-      </tr>
-      % if action.errorCode:
-        <tr>
-          <td>${ _('Error Code') }</td>
-          <td>${ action.errorCode }</td>
-        </tr>
-      % endif
-      % if action.errorMessage:
-        <tr>
-          <td>${ _('Error Message') }</td>
-          <td>${ action.errorMessage }</td>
-        </tr>
-      % endif
-    </tbody>
-  </table>
-
-  <h2>${ _('Details') }</h2>
-
-  <table class="table table-condensed datatables" id="jobTable">
-    <thead>
-      <tr>
-        <th>${ _('Property') }</th>
-        <th>${ _('Value') }</th>
-      </tr>
-    </thead>
-    <tbody>
-      <tr>
-        <td>${ _('External Id') }</td>
-        <td>
+ <div class="row-fluid">
+    <div class="span2">
+      <div class="well sidebar-nav">
+        <ul class="nav nav-list">
+          <li class="nav-header">${ _('Workflow') }</li>
+          <li>
+            <a title="${ _('Edit workflow') }" href="${ workflow.get_absolute_url() }">${ workflow.appName }</a>
+          </li>
+
+          <li class="nav-header">${ _('Name') }</li>
+          <li>${ action.name }</li>
+
           % if action.externalId:
-            <a href="${ url('jobbrowser.views.single_job', job=action.externalId) }">${ "_".join(action.externalId.split("_")[-2:]) }</a>
+            <li class="nav-header">${ _('External Id') }</li>
+            <li><a href="${ url('jobbrowser.views.single_job', job=action.externalId) }">${ "_".join(action.externalId.split("_")[-2:]) }</a></li>
+
+            <li class="nav-header">${ _('Logs') }</li>
+            <li>
+              <a href="${ url('jobbrowser.views.job_single_logs', job=action.externalId) }" title="${ _('View the logs') }" rel="tooltip"><i class="icon-tasks"></i></a>
+            </li>
           % endif
-        </td>
-      </tr>
-      <tr>
-        <td>${ _('External Status') }</td>
-        <td><span class="label ${ utils.get_status(action.externalStatus) }">${ action.externalStatus }<span></td>
-      </tr>
-      <tr>
-        <td>${ _('Data') }</td>
-        <td>${ action.data }</td>
-      </tr>
-      <tr>
-        <td>${ _('Start time') }</td>
-        <td>${ utils.format_time(action.startTime) }</td>
-      </tr>
-      <tr>
-        <td>${ _('End time') }</td>
-        <td>${ utils.format_time(action.endTime) }</td>
-      </tr>
-      <tr>
-        <td>${ _('Id') }</td>
-        <td>${ action.id }</td>
-      </tr>
-      <tr>
-        <td>${ _('Retries') }</td>
-        <td>${ action.retries }</td>
-      </tr>
-      <tr>
-        <td>${ _('TrackerURI') }</td>
-        <td>${ action.trackerUri }</td>
-      </tr>
-      <tr>
-        <td>${ _('Transition') }</td>
-        <td>${ action.transition }</td>
-      </tr>
-    </tbody>
-  </table>
-
-  <br/>
-  <a class="btn" onclick="history.back()">${ _('Back') }</a>
+
+          <li class="nav-header">${ _('Type') }</li>
+          <li>${ action.type }</li>
+
+          <li class="nav-header">${ _('Status') }</li>
+          <li id="status"><span class="label ${ utils.get_status(action.status) }">${ action.status }</span></li>
+        </ul>
+      </div>
+    </div>
+
+    <div class="span9">
+      <ul class="nav nav-tabs">
+        <li class="active"><a href="#details" data-toggle="tab">${ _('Details') }</a></li>
+        <li><a href="#configuration" data-toggle="tab">${ _('Configuration') }</a></li>
+      </ul>
+
+      <div id="workflow-tab-content" class="tab-content" style="min-height:200px">
+
+        <div class="tab-pane active" id="details">
+          <table class="table table-condensed datatables" id="jobTable">
+            <thead>
+              <tr>
+                <th>${ _('Property') }</th>
+                <th>${ _('Value') }</th>
+              </tr>
+            </thead>
+            <tbody>
+              <tr>
+                <td>${ _('External Status') }</td>
+                <td><span class="label ${ utils.get_status(action.externalStatus) }">${ action.externalStatus }<span></td>
+              </tr>
+              <tr>
+                <td>${ _('Data') }</td>
+                <td>${ action.data }</td>
+              </tr>
+              <tr>
+                <td>${ _('Start time') }</td>
+                <td>${ utils.format_time(action.startTime) }</td>
+              </tr>
+              <tr>
+                <td>${ _('End time') }</td>
+                <td>${ utils.format_time(action.endTime) }</td>
+              </tr>
+              <tr>
+                <td>${ _('Id') }</td>
+                <td>${ action.id }</td>
+              </tr>
+              % if action.errorCode:
+                <tr>
+                  <td>${ _('Error Code') }</td>
+                  <td>${ action.errorCode }</td>
+                </tr>
+              % endif
+              % if action.errorMessage:
+                <tr>
+                  <td>${ _('Error Message') }</td>
+                  <td>${ action.errorMessage }</td>
+                </tr>
+              % endif
+              <tr>
+                <td>${ _('Retries') }</td>
+                <td>${ action.retries }</td>
+              </tr>
+              <tr>
+                <td>${ _('TrackerURI') }</td>
+                <td>${ action.trackerUri }</td>
+              </tr>
+              <tr>
+                <td>${ _('Transition') }</td>
+                <td>${ action.transition }</td>
+              </tr>
+            </tbody>
+          </table>
+        </div>
+
+        <div id="configuration" class="tab-pane">
+          ${ utils.display_conf(action.conf_dict) }
+        </div>
+
+        % if action.externalId:
+          <div id="logs" class="tab-pane">
+            <h2>${ _('Logs') }</h2>
+
+            ${ utils.display_conf(action.conf_dict) }
+          </div>
+        % endif
+      </div>
+
+      <div style="margin-bottom: 16px">
+        <a class="btn" onclick="history.back()">${ _('Back') }</a>
+      </div>
+    </div>
+  </div>
+
 </div>
 
 ${ commonfooter(messages) | n,unicode }

+ 69 - 0
apps/oozie/src/oozie/templates/editor/create_bundle.mako

@@ -0,0 +1,69 @@
+## Licensed to Cloudera, Inc. under one
+## or more contributor license agreements.  See the NOTICE file
+## distributed with this work for additional information
+## regarding copyright ownership.  Cloudera, Inc. licenses this file
+## to you under the Apache License, Version 2.0 (the
+## "License"); you may not use this file except in compliance
+## with the License.  You may obtain a copy of the License at
+##
+##     http://www.apache.org/licenses/LICENSE-2.0
+##
+## Unless required by applicable law or agreed to in writing, software
+## distributed under the License is distributed on an "AS IS" BASIS,
+## WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+## See the License for the specific language governing permissions and
+## limitations under the License.
+
+<%!
+  from desktop.views import commonheader, commonfooter
+  from django.utils.translation import ugettext as _
+%>
+
+<%namespace name="layout" file="../navigation-bar.mako" />
+<%namespace name="utils" file="../utils.inc.mako" />
+
+${ commonheader(_("Oozie App"), "oozie", user, "100px") | n,unicode }
+${ layout.menubar(section='bundles') }
+
+
+<div class="container-fluid">
+  <h1>${ _('Create Bundle') }</h1>
+
+    <div class="well">
+      <br/>
+    </div>
+
+    <div style="min-height:300px">
+      <form class="form-horizontal" id="bundleForm" action="${ url('oozie:create_bundle') }" method="POST">
+
+      <div class="row-fluid">
+        <div class="span2">
+        </div>
+        <div class="span8">
+          <h2>${ _('Properties') }</h2>
+          <br/>
+          <fieldset>
+            ${ utils.render_field(bundle_form['name']) }
+            ${ utils.render_field(bundle_form['description']) }
+            ${ utils.render_field(bundle_form['kick_off_time']) }
+            ${ utils.render_field(bundle_form['is_shared']) }
+
+            ${ bundle_form['schema_version'] | n,unicode }
+            ${ bundle_form['parameters'] | n,unicode }
+         </fieldset>
+
+        <div class="span2"></div>
+        </div>
+      </div>
+
+      <div class="form-actions center">
+        <input class="btn btn-primary" type="submit" value="${ _('Save') }" />
+        <a class="btn" onclick="history.back()">${ _('Back') }</a>
+      </div>
+      </form>
+    </div>
+</div>
+
+${ utils.decorate_datetime_fields() }
+
+${ commonfooter(messages) | n,unicode }

+ 54 - 0
apps/oozie/src/oozie/templates/editor/create_bundled_coordinator.mako

@@ -0,0 +1,54 @@
+## Licensed to Cloudera, Inc. under one
+## or more contributor license agreements.  See the NOTICE file
+## distributed with this work for additional information
+## regarding copyright ownership.  Cloudera, Inc. licenses this file
+## to you under the Apache License, Version 2.0 (the
+## "License"); you may not use this file except in compliance
+## with the License.  You may obtain a copy of the License at
+##
+##     http://www.apache.org/licenses/LICENSE-2.0
+##
+## Unless required by applicable law or agreed to in writing, software
+## distributed under the License is distributed on an "AS IS" BASIS,
+## WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+## See the License for the specific language governing permissions and
+## limitations under the License.
+
+<%!
+  from django.utils.translation import ugettext as _
+%>
+
+<%namespace name="properties" file="coordinator_properties.mako" />
+<%namespace name="utils" file="../utils.inc.mako" />
+
+
+<div class="alert alert-info">
+  <h3>${ _('Add coordinator to the Bundle') }</h3>
+</div>
+<div>
+  ${ utils.render_field_no_popover(bundled_coordinator_form['coordinator']) }
+  ${ bundled_coordinator_form['parameters'] | n,unicode }
+  ${ properties.print_key_value(bundled_coordinator_form['parameters'], 'create_bundled_coordinator_parameters') }
+
+  <div class="form-actions" style="padding-left:10px">
+    <a id="createBundledCoordinatorFormBtn" class="btn btn-primary">${ _('Save') }</a>
+  </div>
+</div>
+
+<script type="text/javascript" charset="utf-8">
+$(document).ready(function () {
+  $('#createBundledCoordinatorFormBtn').click(function () {
+     $("#id_create-bundled-coordinator-parameters").val(ko.utils.stringifyJson(window.viewModel.create_bundled_coordinator_parameters));
+
+     $.post("${ url('oozie:create_bundled_coordinator', bundle=bundle.id) }",
+       $("#jobForm").serialize(), function(response) {
+         if (response['status'] != 0) {
+           $('#createBundledCoordinator').html(response['data']);
+         } else {
+           window.location.replace(response['data']);
+           window.location.reload();
+         }
+     });
+  });
+});
+</script>

+ 3 - 3
apps/oozie/src/oozie/templates/editor/create_coordinator_dataset.mako

@@ -27,9 +27,9 @@
   ${ utils.render_field_no_popover(dataset_form['name']) }
   ${ utils.render_field_no_popover(dataset_form['description']) }
   <div class="row-fluid">
-      <div class="alert alert-warning">
-        ${ _('UTC time only! (e.g. if you want 10pm PST (UTC+8) set it 8 hours later to 6am the next day.') }
-      </div>
+    <div class="alert alert-warning">
+      ${ _('UTC time only. (e.g. if you want 10pm PST (UTC+8) set it 8 hours later to 6am the next day.') }
+    </div>
   </div>
   ${ utils.render_field_no_popover(dataset_form['start']) }
   ${ utils.render_field_no_popover(dataset_form['timezone']) }

+ 527 - 0
apps/oozie/src/oozie/templates/editor/edit_bundle.mako

@@ -0,0 +1,527 @@
+## Licensed to Cloudera, Inc. under one
+## or more contributor license agreements.  See the NOTICE file
+## distributed with this work for additional information
+## regarding copyright ownership.  Cloudera, Inc. licenses this file
+## to you under the Apache License, Version 2.0 (the
+## "License"); you may not use this file except in compliance
+## with the License.  You may obtain a copy of the License at
+##
+##     http://www.apache.org/licenses/LICENSE-2.0
+##
+## Unless required by applicable law or agreed to in writing, software
+## distributed under the License is distributed on an "AS IS" BASIS,
+## WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+## See the License for the specific language governing permissions and
+## limitations under the License.
+
+<%!
+  from desktop.views import commonheader, commonfooter
+  from django.utils.translation import ugettext as _
+%>
+
+<%namespace name="layout" file="../navigation-bar.mako" />
+<%namespace name="properties" file="coordinator_properties.mako" />
+<%namespace name="utils" file="../utils.inc.mako" />
+
+${ commonheader(_("Oozie App"), "oozie", user, "100px") | n,unicode }
+${ layout.menubar(section='bundles') }
+
+<style type="text/css">
+  .steps {
+    min-height: 350px;
+    margin-top: 10px;
+  }
+  #add-dataset-form, #edit-dataset-form {
+    display: none;
+  }
+  .nav {
+    margin-bottom: 0;
+  }
+  .help-block {
+    color: #999999;
+  }
+  .sidebar-nav {
+    padding: 9px 0;
+  }
+</style>
+
+<script src="/static/ext/js/knockout-2.1.0.js" type="text/javascript" charset="utf-8"></script>
+<script src="/static/ext/js/routie-0.3.0.min.js" type="text/javascript" charset="utf-8"></script>
+
+
+<div class="container-fluid">
+  <h1>${ _('Bundle Editor : ') } ${ bundle.name }</h1>
+
+  <div class="row-fluid">
+    <div class="span2">
+      <div class="well sidebar-nav">
+        <ul class="nav nav-list">
+          <li class="nav-header">${ _('Properties') }</li>
+          <li class="active"><a href="#properties">${ _('Edit properties') }</a></li>
+
+          <li class="nav-header">${ _('Coordinators') }</li>
+          % if bundle.is_editable(user):
+          <li><a href="#addBundledCoordinator">${ _('Add') }</a></li>
+          % endif
+          <li><a href="#listCoordinators">${ _('Show selected') }</a></li>
+
+          % if bundle.is_editable(user):
+              <li class="nav-header">${ _('History') }</li>
+              <li><a href="#listHistory">${ _('Show history') }</a></li>
+          % endif
+
+          % if bundle:
+              <li class="nav-header">${ _('Actions') }</li>
+              <li>
+                <a id="submit-btn" href="javascript:void(0)" data-submit-url="${ url('oozie:submit_bundle', bundle=bundle.id) }"
+                   title="${ _('Submit this bundle') }" rel="tooltip" data-placement="right"><i class="icon-play"></i> ${ _('Submit') }
+                </a>
+              </li>
+              <li>
+                <a id="clone-btn" href="javascript:void(0)" data-clone-url="${ url('oozie:clone_bundle', bundle=bundle.id) }"
+                   title="${ _('Clone this bundle') }" rel="tooltip" data-placement="right"><i class="icon-retweet"></i> ${ _('Clone') }
+                </a>
+             </li>
+          % endif
+
+        </ul>
+      </div>
+    </div>
+
+    <div class="span10">
+      <form id="jobForm" class="form-horizontal" action="${ url('oozie:edit_bundle', bundle=bundle.id) }" method="POST">
+
+      <div id="properties" class="section">
+        <ul class="nav nav-pills">
+          <li class="active"><a href="#step1" class="step">${ _('Step 1: General') }</a></li>
+          <li><a href="#step2" class="step">${ _('Step 2: Advanced settings') }</a></li>
+        </ul>
+
+        ${ bundled_coordinator_formset.management_form | n,unicode }
+
+        <div class="steps">
+            <div id="step1" class="stepDetails">
+              <div class="alert alert-info"><h3>${ _('Bundle data') }</h3></div>
+              <div class="fieldWrapper">
+                ${ utils.render_field_no_popover(bundle_form['name'], extra_attrs = {'validate':'true'}) }
+                ${ utils.render_field_no_popover(bundle_form['description']) }
+                <div class="row-fluid">
+                  <div class="span6">
+                    ${ utils.render_field(bundle_form['kick_off_time']) }
+                  </div>
+                  <div class="span6">
+                    <div class="alert alert-warning">
+                      ${ _('UTC time only. (e.g. if you want 10pm PST (UTC+8) set it 8 hours later to 6am the next day.') }
+                    </div>
+                  </div>
+                </div>
+                ${ utils.render_field_no_popover(bundle_form['is_shared']) }
+                ${ bundle_form['parameters'] | n,unicode }
+                <div class="hide">
+                  ${ bundle_form['schema_version']  | n,unicode }
+                </div>
+              </div>
+            </div>
+
+            <div id="step2" class="stepDetails hide">
+              <div class="alert alert-info"><h3>${ _('Advanced settings') }</h3></div>
+              ${ properties.print_key_value(bundle_form['parameters'], 'parameters') }
+              ${ bundle_form['schema_version'] | n,unicode }
+            </div>
+          </div>
+
+        <div class="form-actions">
+          <a id="backBtn" class="btn disabled">${ _('Back') }</a>
+          <a id="nextBtn" class="btn btn-primary disable-feedback">${ _('Next') }</a>
+          % if bundle.is_editable(user):
+            <input type="submit" class="btn btn-primary save" style="margin-left: 30px" value="${ _('Save bundle') }"></input>
+          % endif
+        </div>
+      </div>
+
+
+        <div id="listCoordinators" class="section hide">
+          <div class="alert alert-info">
+            <h3>${ _('Coordinators') }</h3>
+          </div>
+
+          <div>
+            % if bundled_coordinator_formset.forms:
+            <table class="table table-striped table-condensed" cellpadding="0" cellspacing="0" data-missing="#bundled_coordinator_missing">
+              <thead>
+                <tr>
+                  <th data-row-selector-exclude="true">${ _('Name') }</th>
+                  <th>${ _('Description') }</th>
+                  % if bundle.is_editable(user):
+                    <th>${ _('Delete') }</th>
+                  % endif
+                </tr>
+              </thead>
+              <tbody>
+              % for form in bundled_coordinator_formset.forms:
+                % for hidden in form.hidden_fields():
+                  ${ hidden | n,unicode }
+                % endfor
+                <tr title="${ _('Click to view the coordinator') }" rel="tooltip">
+                  <td>
+                    <a href="${ url('oozie:edit_coordinator', coordinator=form.instance.coordinator.id) }" target="_blank">
+                    <i class="icon-share-alt"></i> ${ form.instance.coordinator.name }
+                    </a>
+                  </td>
+                  <td>
+                    % if bundle.is_editable(user):
+                      <a href="javascript:void(0)" class="editBundledCoordinator"
+                         data-url="${ url('oozie:edit_bundled_coordinator', bundle=bundle.id, bundled_coordinator=form.instance.id) }" data-row-selector="true"
+                         />
+                    % endif
+                    ${ form.instance.coordinator.description }
+                  </td>
+                  % if bundle.is_editable(user):
+                    <td data-row-selector-exclude="true">
+                      <a class="btn btn-small delete-row" href="javascript:void(0);">${ _('Delete') }${ form['DELETE'] | n,unicode }</a>
+                    </td>
+                  % endif
+                </tr>
+
+                <div class="hide">
+                  % for field in form.visible_fields():
+                  ${ field.errors | n,unicode }
+                  ${ field.label }: ${ field | n,unicode }
+                  % endfor
+                </div>
+              % endfor
+              </tbody>
+            </table>
+            % endif
+          </div>
+          <%
+            klass = "alert alert-error"
+            if bundled_coordinator_formset.forms:
+              klass += " hide"
+          %>
+          <div id="bundled_coordinator_missing" data-missing-bind="true" class="${ klass }">
+            ${ _('There are currently no coordinator in this bundle.') } <a href="#addBundledCoordinator">${ _('Do you want to add a coordinator ?') }</a>
+          </div>
+            % if bundled_coordinator_formset.forms and bundle.is_editable(user):
+              <div class="form-actions" style="padding-left:10px">
+                <input type="submit" class="btn btn-primary" value="${ _('Save') }"></input>
+              </div>
+            % endif
+        </div>
+
+        <div id="editBundledCoordinator" class="section hide"></div>
+
+        <div id="createBundledCoordinator" class="section hide">
+          ${ bundled_coordinator_html_form | n,unicode }
+        </div>
+
+        <div id="listHistory" class="section hide">
+          <div class="alert alert-info"><h3>${ _('History') }</h3></div>
+          % if bundle.is_editable(user):
+          <div class="tab-pane" id="history">
+            <table class="table">
+              <thead>
+              <tr>
+                <th>${ _('Date') }</th>
+                <th>${ _('Id') }</th>
+              </tr>
+              </thead>
+              <tbody>
+              % if not history:
+              <tr>
+                <td>${ _('N/A') }</td><td></td>
+              </tr>
+              % endif
+              % for record in history:
+              <tr>
+                <td>
+                  <a href="${ url('oozie:list_history_record', record_id=record.id) }" data-row-selector="true"></a>
+                  ${ utils.format_date(record.submission_date) }
+                </td>
+                <td>${ record.oozie_job_id }</td>
+              </tr>
+              % endfor
+              </tbody>
+            </table>
+          </div>
+          % endif
+        </div>
+
+    </div>
+
+  </div>
+  </form>
+
+</div>
+
+<div id="submit-job-modal" class="modal hide"></div>
+
+
+% if bundle.id:
+  <div class="modal hide" id="edit-dataset-modal" style="z-index:1500;width:850px"></div>
+
+  <style type="text/css">
+    .delete-row input {
+      display: none;
+    }
+  </style>
+
+  <script src="/static/ext/js/knockout-2.1.0.js" type="text/javascript" charset="utf-8"></script>
+
+  <script type="text/javascript" charset="utf-8">
+
+    /**
+     * Initial state is used to define when to display the "initial state" of a table.
+     * IE: if there are no formset forms to display, show an "empty" message.
+     *
+     * First, we build a registry of all functions that need to pass in order for us to display the initial state.
+     * Things that 'remove' or 'add' elements will need to trigger 'reinit' and 'initOff' events on their respective 'initial state' elements.
+     * 'Initial state' elements should have 'data-missing-bind="true"' so that custom events can be binded to them.
+     *
+     * args:
+     *  test_func - function that, if true, will indicate that the initial state should be shown.
+     *  hook - If we do show the initial state, run this function before showing it.
+     */
+    var initialStateRegistry = {};
+
+    $("*[data-missing-bind='true']").on('register', function (e, test_func, hook) {
+      var id = $(this).attr('id');
+      if (!initialStateRegistry[id]) {
+        initialStateRegistry[id] = [];
+      }
+      initialStateRegistry[id].push({ test:test_func, hook:hook });
+    });
+
+    $("*[data-missing-bind='true']").on('reinit', function (e) {
+      var show = true;
+      var id = $(this).attr('id');
+      for (var i in initialStateRegistry[id]) {
+        show = show && initialStateRegistry[id][i].test();
+      }
+      if (show) {
+        for (var i in initialStateRegistry[id]) {
+          if (!!initialStateRegistry[id][i].hook) {
+            initialStateRegistry[id][i].hook();
+          }
+        }
+        $(this).show();
+      }
+    });
+
+    $("*[data-missing-bind='true']").on('initOff', function (e) {
+      $(this).hide();
+    });
+
+
+    $(document).ready(function () {
+      var ViewModel = function() {
+        var self = this;
+
+        self.parameters = ko.observableArray(${ bundle.parameters | n });
+        self.add_parameters = function() {
+          self.parameters.push({name: "", value: ""});
+        };
+        self.remove_parameters = function(val) {
+          self.parameters.remove(val);
+        };
+
+        self.create_bundled_coordinator_parameters = ko.observableArray([]);
+        self.add_create_bundled_coordinator_parameters = function() {
+          self.create_bundled_coordinator_parameters.push({name: "", value: ""});
+        };
+        self.remove_create_bundled_coordinator_parameters = function(val) {
+          self.create_bundled_coordinator_parameters.remove(val);
+        };
+
+        self.saveBundle = function(form) {
+          var form = $("#jobForm");
+
+          $("<input>").attr("type", "hidden")
+                  .attr("name", "parameters")
+                  .attr("value", ko.utils.stringifyJson(self.parameters))
+                  .appendTo(form);
+
+          return true;
+        };
+      };
+
+
+      window.viewModel = new ViewModel();
+      ko.applyBindings(window.viewModel, document.getElementById('step2'));
+      ko.applyBindings(window.viewModel, document.getElementById('createBundledCoordinator'));
+
+      % if not bundle.is_editable(user):
+        $("#jobForm input, select").attr("disabled", "disabled");
+        $("#jobForm .btn").not("#nextBtn, #backBtn").attr("disabled", "disabled").addClass("btn-disabled");
+      % endif
+
+      $('.delete-row').click(function () {
+        var el = $(this);
+        var row = el.closest('tr');
+        var table = el.closest('table');
+        el.find(':input').attr('checked', 'checked');
+        row.hide();
+        $(table.attr('data-missing')).trigger('reinit', table);
+      });
+
+      $('.delete-row').closest('table').each(function () {
+        var table = $(this);
+        var id = table.attr('data-missing');
+        if (!!id) {
+          $(id).trigger('register', [ function () {
+            return table.find('tbody tr').length == table.find('tbody tr:hidden').length;
+          }, function () {
+            table.hide();
+          } ]);
+        }
+      });
+
+      $(".editBundledCoordinator").click(function () {
+        var el = $(this);
+        $.ajax({
+          url:el.data("url"),
+          beforeSend:function (xhr) {
+            xhr.setRequestHeader("X-Requested-With", "Hue");
+          },
+          dataType:"json",
+          success:function (response) {
+            $("#editBundledCoordinator").html(response['data']);
+            routie("editBundledCoordinator");
+          }
+        });
+      });
+
+      $("a[data-row-selector='true']").jHueRowSelector();
+
+
+      $("*[rel=popover]").popover({
+        placement:'top',
+        trigger:'hover'
+      });
+
+      var currentStep = "step1";
+
+      routie({
+        "step1":function () {
+          showStep("step1");
+        },
+        "step2":function () {
+          if (validateStep("step1")) {
+            showStep("step2");
+          }
+        },
+        "properties":function () {
+          showSection("properties");
+        },
+        "listCoordinators":function () {
+          showSection("listCoordinators");
+        },
+        "addBundledCoordinator":function () {
+          showSection("createBundledCoordinator");
+        },
+        "editBundledCoordinator":function () {
+          showSection("editBundledCoordinator");
+        },
+        "listHistory":function () {
+          showSection("listHistory");
+        }
+      });
+
+      function highlightMenu(section) {
+        $(".nav-list li").removeClass("active");
+        $("a[href='#" + section + "']").parent().addClass("active");
+      }
+
+      function showStep(step) {
+        showSection("properties");
+        currentStep = step;
+        if (step != "step1") {
+          $("#backBtn").removeClass("disabled");
+        }
+        else {
+          $("#backBtn").addClass("disabled");
+        }
+        if (step != $(".stepDetails:last").attr("id")) {
+          $("#nextBtn").removeClass("disabled");
+        }
+        else {
+          $("#nextBtn").addClass("disabled");
+        }
+        $("a.step").parent().removeClass("active");
+        $("a.step[href=#" + step + "]").parent().addClass("active");
+        $(".stepDetails").hide();
+        $("#" + step).show();
+      }
+
+      function showSection(section) {
+        $(".section").hide();
+        $("#" + section).show();
+        highlightMenu(section);
+      }
+
+      function validateStep(step) {
+        var proceed = true;
+        $("#" + step).find("[validate=true]").each(function () {
+          if ($(this).val().trim() == "") {
+            proceed = false;
+            routie(step);
+            $(this).parents(".control-group").addClass("error");
+            $(this).parent().find(".help-inline").remove();
+            $(this).after("<span class=\"help-inline\"><strong>${ _('This field is required.') }</strong></span>");
+          }
+        });
+        return proceed;
+      }
+
+      $("#backBtn").click(function () {
+        var nextStep = (currentStep.substr(4) * 1 - 1);
+        if (nextStep >= 1) {
+          routie("step" + nextStep);
+        }
+      });
+
+      $("#nextBtn").click(function () {
+        var nextStep = (currentStep.substr(4) * 1 + 1);
+        if (nextStep <= $(".step").length) {
+          routie("step" + nextStep);
+        }
+      });
+
+      $("[validate=true]").change(function () {
+        $(this).parents(".control-group").removeClass("error");
+        $(this).parent().find(".help-inline").remove();
+      });
+
+      $("#id_workflow").change(function () {
+        $("#workflowName").text($("#id_workflow option[value='" + $(this).val() + "']").text());
+      });
+
+      $("#clone-btn").on("click", function () {
+        var _url = $(this).data("clone-url");
+        $.post(_url, function (data) {
+          window.location = data.url;
+        });
+      });
+
+      $("#submit-btn").on("click", function () {
+        var _url = $(this).data("submit-url");
+        $.get(_url, function (response) {
+            $("#submit-job-modal").html(response);
+            $("#submit-job-modal").modal("show");
+          }
+        );
+      });
+
+      $(".save").click(function () {
+        window.viewModel.saveBundle();
+      });
+
+      $("a[rel='tooltip']").tooltip();
+    });
+  </script>
+% endif
+
+
+${ utils.decorate_datetime_fields() }
+
+${ commonfooter(messages) | n,unicode }

+ 72 - 0
apps/oozie/src/oozie/templates/editor/edit_bundled_coordinator.mako

@@ -0,0 +1,72 @@
+## Licensed to Cloudera, Inc. under one
+## or more contributor license agreements.  See the NOTICE file
+## distributed with this work for additional information
+## regarding copyright ownership.  Cloudera, Inc. licenses this file
+## to you under the Apache License, Version 2.0 (the
+## "License"); you may not use this file except in compliance
+## with the License.  You may obtain a copy of the License at
+##
+##     http://www.apache.org/licenses/LICENSE-2.0
+##
+## Unless required by applicable law or agreed to in writing, software
+## distributed under the License is distributed on an "AS IS" BASIS,
+## WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+## See the License for the specific language governing permissions and
+## limitations under the License.
+
+<%!
+  from django.utils.translation import ugettext as _
+%>
+
+<%namespace name="properties" file="coordinator_properties.mako" />
+<%namespace name="utils" file="../utils.inc.mako" />
+
+
+<div class="alert alert-info">
+  <h3>${ _('Edit bundled coordinator') }</h3>
+</div>
+<div>
+  ${ utils.render_field_no_popover(bundled_coordinator_form['coordinator']) }
+  ${ bundled_coordinator_form['parameters'] | n,unicode }
+  ${ properties.print_key_value(bundled_coordinator_form['parameters'], 'edit_bundled_coordinator_parameters') }
+
+  <div class="form-actions" style="padding-left:10px">
+    <a id="editBundledCoordinatorFormBtn" class="btn btn-primary">${ _('Save') }</a>
+  </div>
+</div>
+
+
+<script type="text/javascript" charset="utf-8">
+$(document).ready(function () {
+
+  var ViewModel = function() {
+    var self = this;
+
+    self.edit_bundled_coordinator_parameters = ko.observableArray(${ bundled_coordinator_instance.parameters | n,unicode });
+    self.add_edit_bundled_coordinator_parameters = function() {
+      self.edit_bundled_coordinator_parameters.push({name: "", value: ""});
+    };
+    self.remove_edit_bundled_coordinator_parameters = function(val) {
+      self.edit_bundled_coordinator_parameters.remove(val);
+    };
+  };
+
+
+  window.viewModel = new ViewModel();
+  ko.applyBindings(window.viewModel, document.getElementById('editBundledCoordinator'));
+
+  $('#editBundledCoordinatorFormBtn').click(function () {
+     $("#id_edit-bundled-coordinator-parameters").val(ko.utils.stringifyJson(window.viewModel.edit_bundled_coordinator_parameters));
+
+     $.post("${ url('oozie:edit_bundled_coordinator', bundle=bundle.id, bundled_coordinator=bundled_coordinator_form.instance.id) }",
+       $("#jobForm").serialize(), function(response) {
+         if (response['status'] != 0) {
+           $('#editBundledCoordinator').html(response['data']);
+         } else {
+           window.location.replace(response['data']);
+           window.location.reload();
+         }
+     });
+  });
+});
+</script>

+ 2 - 2
apps/oozie/src/oozie/templates/editor/edit_coordinator.mako

@@ -132,7 +132,7 @@ ${ layout.menubar(section='coordinators') }
             <div class="fieldWrapper">
               <div class="row-fluid">
                   <div class="alert alert-warning">
-                    ${ _('UTC time only! (e.g. if you want 10pm PST (UTC+8) set it 8 hours later to 6am the next day.') }
+                    ${ _('UTC time only. (e.g. if you want 10pm PST (UTC+8) set it 8 hours later to 6am the next day.') }
                   </div>
               </div>
               <div class="row-fluid">
@@ -329,7 +329,7 @@ ${ layout.menubar(section='coordinators') }
                 % for hidden in form.hidden_fields():
                   ${ hidden | n,unicode }
                 % endfor
-                <tr>
+                <tr title="${ _('Click to view the dataset') }" rel="tooltip">
                   <td>
                   % if coordinator.is_editable(user):
                     <a href="javascript:void(0)" class="editDataset" data-url="${ url('oozie:edit_coordinator_dataset', dataset=form.instance.id) }" data-row-selector="true"/>

+ 4 - 1
apps/oozie/src/oozie/templates/editor/edit_coordinator_dataset.mako

@@ -26,12 +26,15 @@
 <fieldset>
   ${ utils.render_field(dataset_form['name']) }
   ${ utils.render_field(dataset_form['description']) }
+
   <div class="row-fluid">
     <div class="span6">
       ${ utils.render_field(dataset_form['start']) }
     </div>
     <div class="span6">
-    <div class="alert alert-warning">${ _('Recommended to be before the coordinator start date.') }</div>
+      <div class="alert alert-warning">
+        ${ _('UTC time only.') } ${ _('Recommended to be before the coordinator start date.') }
+      </div>
     </div>
   </div>
   <div class="row-fluid">

+ 8 - 8
apps/oozie/src/oozie/templates/editor/edit_workflow.mako

@@ -49,9 +49,9 @@ ${ layout.menubar(section='workflows') }
         <li><a href="#editWorkflow">${ _('Edit workflow') }</a></li>
         <li><a href="javascript:void(0)" class="import-jobsub-node-link" title="${ _('Click to import a Job Designer action and add it to the end of the flow') }" rel="tooltip" data-placement="right">${ _('Import action') }</a></li>
         % if user_can_edit_job:
-            <li>
-              <a data-bind="attr: {href: '/filebrowser/view' + deployment_dir() }" target="_blank" title="${ _('Upload additional files and libraries to the deployment directory') }" rel="tooltip" data-placement="right"><i class="icon-share-alt"></i> ${ _('Upload') }</a>
-            </li>
+          <li>
+            <a data-bind="attr: {href: '/filebrowser/view' + deployment_dir() }" target="_blank" title="${ _('Upload additional files and libraries to the deployment directory') }" rel="tooltip" data-placement="right"><i class="icon-share-alt"></i> ${ _('Upload') }</a>
+          </li>
         % endif
 
         % if user_can_edit_job:
@@ -78,9 +78,9 @@ ${ layout.menubar(section='workflows') }
     <div id="properties" class="section hide">
       <div class="alert alert-info"><h3>${ _('Properties') }</h3></div>
       <fieldset>
-      ${ utils.render_field(workflow_form['name'], extra_attrs={'data-bind': 'value: %s' % workflow_form['name'].name}) }
-      ${ utils.render_field(workflow_form['description'], extra_attrs={'data-bind': 'value: %s' % workflow_form['description'].name}) }
-      ${ utils.render_field(workflow_form['is_shared'], extra_attrs={'data-bind': 'checked: %s' % workflow_form['is_shared'].name}) }
+        ${ utils.render_field(workflow_form['name'], extra_attrs={'data-bind': 'value: %s' % workflow_form['name'].name}) }
+        ${ utils.render_field(workflow_form['description'], extra_attrs={'data-bind': 'value: %s' % workflow_form['description'].name}) }
+        ${ utils.render_field(workflow_form['is_shared'], extra_attrs={'data-bind': 'checked: %s' % workflow_form['is_shared'].name}) }
 
       <%
       workflows.key_value_field(workflow_form['parameters'], {
@@ -214,7 +214,7 @@ ${ layout.menubar(section='workflows') }
     <div id="listHistory" class="section hide">
       <div class="alert alert-info"><h3>${ _('History') }</h3></div>
       % if not history:
-      ${ _('N/A') }
+        ${ _('N/A') }
       % else:
         <table class="table">
           <thead>
@@ -228,7 +228,7 @@ ${ layout.menubar(section='workflows') }
           <tr>
             <td>
               <a href="${ url('oozie:list_history_record', record_id=record.id) }" data-row-selector="true"></a>
-            ${ utils.format_date(record.submission_date) }
+              ${ utils.format_date(record.submission_date) }
             </td>
             <td>${ record.oozie_job_id }</td>
           </tr>

+ 53 - 0
apps/oozie/src/oozie/templates/editor/gen/bundle.xml.mako

@@ -0,0 +1,53 @@
+## Licensed to Cloudera, Inc. under one
+## or more contributor license agreements.  See the NOTICE file
+## distributed with this work for additional information
+## regarding copyright ownership.  Cloudera, Inc. licenses this file
+## to you under the Apache License, Version 2.0 (the
+## "License"); you may not use this file except in compliance
+## with the License.  You may obtain a copy of the License at
+##
+##     http://www.apache.org/licenses/LICENSE-2.0
+##
+## Unless required by applicable law or agreed to in writing, software
+## distributed under the License is distributed on an "AS IS" BASIS,
+## WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+## See the License for the specific language governing permissions and
+## limitations under the License.
+
+
+<bundle-app name="${ bundle.name }"
+  xmlns:xsi='http://www.w3.org/2001/XMLSchema-instance'
+  xmlns="${ bundle.schema_version }">
+  % if bundle.get_parameters():
+  <parameters>
+    % for p in bundle.get_parameters():
+    <property>
+        <name>${ p['name'] }</name>
+        <value>${ p['value'] }</value>
+    </property>
+    % endfor
+  </parameters>
+  % endif
+
+  <controls>
+     <kick-off-time>${ bundle.kick_off_time_utc }</kick-off-time>
+  </controls>
+
+  % if bundle.coordinators:
+    % for bundled in bundle.coordinators.all():
+    <coordinator name='${ bundled.coordinator.name }' >
+       <app-path>${'${'}nameNode}${ bundled.coordinator.deployment_dir }</app-path>
+       % if bundled.get_parameters():
+         <configuration>
+           % for param in bundled.get_parameters():
+           <property>
+              <name>${ param['name'] }</name>
+              <value>${ param['value'] }</value>
+          </property>
+          % endfor
+        </configuration>
+      % endif
+    </coordinator>
+    % endfor
+  % endif
+</bundle-app>

+ 1 - 1
apps/oozie/src/oozie/templates/editor/gen/workflow-graph-status.xml.mako

@@ -63,7 +63,7 @@
           </div>
           <div class="span10" style="text-align:right">
             % if not is_fork and action:
-            <a href="${ url('oozie:list_oozie_workflow_action', action=action.id) }" class="btn btn-mini" title="${ _('View workflow action') }" rel="tooltip"><i class="icon-eye-open"></i></a>
+            <a href="${ action.get_absolute_url() }" class="btn btn-mini" title="${ _('View workflow action') }" rel="tooltip"><i class="icon-eye-open"></i></a>
             % endif
             <a href="${ url('jobbrowser.views.job_single_logs', job=action.externalId) }" class="btn btn-mini" title="${ _('View the logs') }" rel="tooltip" data-row-selector-exclude="true" id="advanced-btn"><i class="icon-tasks"></i></a>
             &nbsp;

+ 245 - 0
apps/oozie/src/oozie/templates/editor/list_bundles.mako

@@ -0,0 +1,245 @@
+## Licensed to Cloudera, Inc. under one
+## or more contributor license agreements.  See the NOTICE file
+## distributed with this work for additional information
+## regarding copyright ownership.  Cloudera, Inc. licenses this file
+## to you under the Apache License, Version 2.0 (the
+## "License"); you may not use this file except in compliance
+## with the License.  You may obtain a copy of the License at
+##
+##     http://www.apache.org/licenses/LICENSE-2.0
+##
+## Unless required by applicable law or agreed to in writing, software
+## distributed under the License is distributed on an "AS IS" BASIS,
+## WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+## See the License for the specific language governing permissions and
+## limitations under the License.
+
+<%!
+  from desktop.views import commonheader, commonfooter
+  from django.utils.translation import ugettext as _
+  import time as py_time
+%>
+
+<%namespace name="actionbar" file="../actionbar.mako" />
+<%namespace name="layout" file="../navigation-bar.mako" />
+<%namespace name="utils" file="../utils.inc.mako" />
+
+${ commonheader(_("Oozie App"), "oozie", user, "100px") | n,unicode }
+${ layout.menubar(section='bundles') }
+
+
+<div class="container-fluid">
+  <h1>${ _('Bundle Manager') }</h1>
+
+  <%actionbar:render>
+    <%def name="actions()">
+        <button class="btn toolbarBtn" id="submit-btn" disabled="disabled"><i class="icon-play"></i> ${ _('Submit') }</button>
+        <button class="btn toolbarBtn" id="clone-btn" disabled="disabled"><i class="icon-retweet"></i> ${ _('Clone') }</button>
+        <button class="btn toolbarBtn" id="delete-btn" disabled="disabled"><i class="icon-remove"></i> ${ _('Delete') }</button>
+    </%def>
+
+    <%def name="creation()">
+        <a href="${ url('oozie:create_bundle') }" class="btn"><i class="icon-plus-sign"></i> ${ _('Create') }</a>
+    </%def>
+  </%actionbar:render>
+
+  <table id="bundleTable" class="table datatables">
+    <thead>
+      <tr>
+        <th width="1%"><div class="hueCheckbox selectAll" data-selectables="bundleCheck"></div></th>
+        <th width="10%">${ _('Name') }</th>
+        <th width="20%">${ _('Description') }</th>
+        <th width="35%">${ _('Coordinators') }</th>
+        <th>${ _('Kick off') }</th>
+        <th>${ _('Status') }</th>
+        <th>${ _('Last Modified') }</th>
+        <th>${ _('Owner') }</th>
+      </tr>
+    </thead>
+    <tbody>
+      % for bundle in jobs:
+        <tr>
+          <td data-row-selector-exclude="true">
+            <div class="hueCheckbox bundleCheck" data-row-selector-exclude="true"
+              % if bundle.is_accessible(currentuser):
+                  data-clone-url="${ url('oozie:clone_bundle', bundle=bundle.id) }"
+                  data-submit-url="${ url('oozie:submit_bundle', bundle=bundle.id) }"
+              % endif
+              % if bundle.is_editable(currentuser):
+                  data-delete-url="${ url('oozie:delete_bundle', bundle=bundle.id) }"
+              % endif
+              >
+            </div>
+            % if bundle.is_accessible(currentuser):
+              <a href="${ url('oozie:edit_bundle', bundle=bundle.id) }" data-row-selector="true"/>
+            % endif
+          </td>
+          <td>${ bundle.name }</td>
+          <td>${ bundle.description }</td>
+          <td>
+             % for bundled in bundle.coordinators.all():
+               ${ bundled.coordinator.name }
+		       % if not loop.last:
+		        ,
+		       % endif
+             % endfor
+          </td>
+          <td>${ bundle.kick_off_time }</td>
+          <td>
+            <span class="label label-info">${ bundle.status }</span>
+          </td>
+          <td nowrap="nowrap" data-sort-value="${py_time.mktime(bundle.last_modified.timetuple())}">${ utils.format_date(bundle.last_modified) }</td>
+          <td>${ bundle.owner.username }</td>
+        </tr>
+      %endfor
+    </tbody>
+  </table>
+</div>
+
+
+<div id="submit-job-modal" class="modal hide"></div>
+
+<div id="delete-job" class="modal hide">
+  <form id="deleteWfForm" action="" method="POST">
+    <div class="modal-header">
+      <a href="#" class="close" data-dismiss="modal">&times;</a>
+      <h3 id="deleteWfMessage">${ _('Delete this bundle?') }</h3>
+    </div>
+    <div class="modal-footer">
+      <a href="#" class="btn" data-dismiss="modal">${ _('No') }</a>
+      <input type="submit" class="btn btn-danger" value="${ _('Yes') }"/>
+    </div>
+  </form>
+</div>
+
+<script src="/static/ext/js/datatables-paging-0.1.js" type="text/javascript" charset="utf-8"></script>
+
+<script type="text/javascript" charset="utf-8">
+  $(document).ready(function () {
+
+    $(".selectAll").click(function () {
+      if ($(this).attr("checked")) {
+        $(this).removeAttr("checked").removeClass("icon-ok");
+        $("." + $(this).data("selectables")).removeClass("icon-ok").removeAttr("checked");
+      }
+      else {
+        $(this).attr("checked", "checked").addClass("icon-ok");
+        $("." + $(this).data("selectables")).addClass("icon-ok").attr("checked", "checked");
+      }
+      toggleActions();
+    });
+
+    $(".bundleCheck").click(function () {
+      if ($(this).attr("checked")) {
+        $(this).removeClass("icon-ok").removeAttr("checked");
+      }
+      else {
+        $(this).addClass("icon-ok").attr("checked", "checked");
+      }
+      $(".selectAll").removeAttr("checked").removeClass("icon-ok");
+      toggleActions();
+    });
+
+    function toggleActions() {
+      $(".toolbarBtn").attr("disabled", "disabled");
+      var selector = $(".hueCheckbox[checked='checked']");
+      if (selector.length == 1) {
+        var action_buttons = [
+          ['#submit-btn', 'data-submit-url'],
+          ['#bundle-btn', 'data-bundle-url'],
+          ['#delete-btn', 'data-delete-url'],
+          ['#clone-btn', 'data-clone-url']
+        ];
+        $.each(action_buttons, function (index) {
+          if (selector.attr(this[1])) {
+            $(this[0]).removeAttr("disabled");
+          } else {
+            $(this[0]).attr("disabled", "disabled");
+          }
+        });
+      }
+    }
+
+    $("#delete-btn").click(function (e) {
+      var _this = $(".hueCheckbox[checked='checked']");
+      var _action = _this.attr("data-delete-url");
+      $("#deleteWfForm").attr("action", _action);
+      $("#deleteWfMessage").text(_this.attr("alt"));
+      $("#delete-job").modal("show");
+    });
+
+    $("#submit-btn").click(function () {
+      var _this = $(".hueCheckbox[checked='checked']");
+      var _action = _this.attr("data-submit-url");
+      $.get(_action, function (response) {
+          $("#submit-job-modal").html(response);
+          $("#submit-job-modal").modal("show");
+        }
+      );
+    });
+
+    $(".deleteConfirmation").click(function () {
+      var _this = $(this);
+      var _action = _this.attr("data-url");
+      $("#deleteWfForm").attr("action", _action);
+      $("#deleteWfMessage").text(_this.attr("alt"));
+      $("#delete-job").modal("show");
+    });
+
+    $("#clone-btn").click(function (e) {
+      var _this = $(".hueCheckbox[checked='checked']");
+      var _url = _this.attr("data-clone-url");
+      $.post(_url, function (data) {
+        window.location = data.url;
+      });
+    });
+
+    var oTable = $("#bundleTable").dataTable({
+      "sPaginationType":"bootstrap",
+      'iDisplayLength':50,
+      "bLengthChange":false,
+      "sDom":"<'row'r>t<'row'<'span8'i><''p>>",
+      "aoColumns":[
+        { "bSortable":false },
+        null,
+        null,
+        null,
+        null,
+        { "sSortDataType":"dom-sort-value", "sType":"numeric" },
+        null,
+        null
+      ],
+      "aaSorting":[
+        [ 6, "desc" ]
+      ],
+      "oLanguage":{
+        "sEmptyTable":"${_('No data available')}",
+        "sInfo":"${_('Showing _START_ to _END_ of _TOTAL_ entries')}",
+        "sInfoEmpty":"${_('Showing 0 to 0 of 0 entries')}",
+        "sInfoFiltered":"${_('(filtered from _MAX_ total entries)')}",
+        "sZeroRecords":"${_('No matching records')}",
+        "oPaginate":{
+          "sFirst":"${_('First')}",
+          "sLast":"${_('Last')}",
+          "sNext":"${_('Next')}",
+          "sPrevious":"${_('Previous')}"
+        }
+      }
+    });
+
+    $("#filterInput").keydown(function (e) {
+      if (e.which == 13) {
+        e.preventDefault();
+        return false;
+      }
+    });
+
+    $("#filterInput").keyup(function () {
+      oTable.fnFilter($(this).val());
+    });
+
+    $("a[data-row-selector='true']").jHueRowSelector();
+  });
+</script>
+
+${ commonfooter(messages) | n,unicode }

+ 2 - 0
apps/oozie/src/oozie/templates/navigation-bar.mako

@@ -28,6 +28,7 @@
         <li class="${utils.is_selected(section, 'dashboard')}"><a href="${url('oozie:list_oozie_workflows')}">${ _('Dashboard') }</a></li>
         <li class="${utils.is_selected(section, 'workflows')}"><a href="${url('oozie:list_workflows')}">${ _('Workflows') }</a></li>
         <li class="${utils.is_selected(section, 'coordinators')}"><a href="${url('oozie:list_coordinators')}">${ _('Coordinators') }</a></li>
+        <li class="${utils.is_selected(section, 'bundles')}"><a href="${url('oozie:list_bundles')}">${ _('Bundles') }</a></li>
         <li class="${utils.is_selected(section, 'history')}"><a href="${url('oozie:list_history')}">${ _('History') }</a></li>
       </ul>
     </div>
@@ -40,6 +41,7 @@
   <ul class="nav nav-tabs">
     <li class="${utils.is_selected(section, 'workflows')}"><a href="${url('oozie:list_oozie_workflows')}">${ _('Workflows') }</a></li>
     <li class="${utils.is_selected(section, 'coordinators')}"><a href="${url('oozie:list_oozie_coordinators')}">${ _('Coordinators') }</a></li>
+    <li class="${utils.is_selected(section, 'bundles')}"><a href="${url('oozie:list_oozie_bundles')}">${ _('Bundles') }</a></li>
   </ul>
 </%def>
 

File diff suppressed because it is too large
+ 10 - 2
apps/oozie/src/oozie/tests.py


+ 15 - 3
apps/oozie/src/oozie/urls.py

@@ -43,6 +43,15 @@ urlpatterns = patterns(
   url(r'^create_coordinator_data/(?P<coordinator>[-\w]+)/(?P<data_type>(input|output))$', 'create_coordinator_data', name='create_coordinator_data'),
   url(r'^submit_coordinator/(?P<coordinator>\d+)$', 'submit_coordinator', name='submit_coordinator'),
 
+  url(r'^list_bundles$', 'list_bundles', name='list_bundles'),
+  url(r'^create_bundle$', 'create_bundle', name='create_bundle'),
+  url(r'^edit_bundle/(?P<bundle>\d+)$', 'edit_bundle', name='edit_bundle'),
+  url(r'^submit_bundle/(?P<bundle>\d+)$', 'submit_bundle', name='submit_bundle'),
+  url(r'^clone_bundle/(?P<bundle>\d+)$', 'clone_bundle', name='clone_bundle'),
+  url(r'^delete_bundle/(?P<bundle>\d+)$', 'delete_bundle', name='delete_bundle'),
+  url(r'^create_bundled_coordinator/(?P<bundle>\d+)$', 'create_bundled_coordinator', name='create_bundled_coordinator'),
+  url(r'^edit_bundled_coordinator/(?P<bundle>\d+)/(?P<bundled_coordinator>\d+)$', 'edit_bundled_coordinator', name='edit_bundled_coordinator'),
+
   url(r'^list_history$', 'list_history', name='list_history'),
   url(r'^list_history/(?P<record_id>[-\w]+)$', 'list_history_record', name='list_history_record'),
   url(r'^setup_app/$', 'setup_app', name='setup_app'),
@@ -68,9 +77,12 @@ urlpatterns += patterns(
 
   url(r'^list_oozie_workflows/$', 'list_oozie_workflows', name='list_oozie_workflows'),
   url(r'^list_oozie_coordinators/$', 'list_oozie_coordinators', name='list_oozie_coordinators'),
-  url(r'^list_oozie_workflow/(?P<job_id>[-\w]+)/(?P<coordinator_job_id>[-\w]+)?$', 'list_oozie_workflow', name='list_oozie_workflow'),
-  url(r'^list_oozie_coordinator/(?P<job_id>[-\w]+)$', 'list_oozie_coordinator', name='list_oozie_coordinator'),
-  url(r'^list_oozie_workflow_action/(?P<action>[-\w@]+)$', 'list_oozie_workflow_action', name='list_oozie_workflow_action'),
+  url(r'^list_oozie_bundles/$', 'list_oozie_bundles', name='list_oozie_bundles'),
+  url(r'^list_oozie_workflow/(?P<job_id>[-\w]+)/(?P<coordinator_job_id>[-\w]+)?/(?P<bundle_job_id>[-\w]+)?$', 'list_oozie_workflow', name='list_oozie_workflow'),
+  url(r'^list_oozie_coordinator/(?P<job_id>[-\w]+)/(?P<bundle_job_id>[-\w]+)?$', 'list_oozie_coordinator', name='list_oozie_coordinator'),
+  url(r'^list_oozie_workflow_action/(?P<action>[-\w@]+)/(?P<coordinator_job_id>[-\w]+)?/(?P<bundle_job_id>[-\w]+)?$', 'list_oozie_workflow_action', name='list_oozie_workflow_action'),
+  url(r'^list_oozie_bundle/(?P<job_id>[-\w]+)$', 'list_oozie_bundle', name='list_oozie_bundle'),
+
   url(r'^rerun_oozie_job/(?P<job_id>[-\w]+)/(?P<app_path>.+?)$', 'rerun_oozie_job', name='rerun_oozie_job'),
   url(r'^rerun_oozie_coord/(?P<job_id>[-\w]+)/(?P<app_path>.+?)$', 'rerun_oozie_coordinator', name='rerun_oozie_coord'),
   url(r'^manage_oozie_jobs/(?P<job_id>[-\w]+)/(?P<action>(start|suspend|resume|kill|rerun))$', 'manage_oozie_jobs', name='manage_oozie_jobs'),

+ 142 - 12
apps/oozie/src/oozie/views/dashboard.py

@@ -140,13 +140,39 @@ def list_oozie_coordinators(request):
 
 
 @show_oozie_error
-def list_oozie_workflow(request, job_id, coordinator_job_id=None):
+def list_oozie_bundles(request):
+  kwargs = {'cnt': OOZIE_JOBS_COUNT.get(),}
+  if not has_dashboard_jobs_access(request.user):
+    kwargs['user'] = request.user.username
+
+  bundles = get_oozie().get_bundles(**kwargs)
+
+  if request.GET.get('format') == 'json':
+    json_jobs = bundles.jobs
+    if request.GET.get('type') == 'running':
+      json_jobs = split_oozie_jobs(bundles.jobs)['running_jobs']
+    if request.GET.get('type') == 'completed':
+      json_jobs = split_oozie_jobs(bundles.jobs)['completed_jobs']
+    return HttpResponse(json.dumps(massaged_oozie_jobs_for_json(json_jobs, request.user)).replace('\\\\', '\\'), mimetype="application/json")
+
+  return render('dashboard/list_oozie_bundles.mako', request, {
+    'jobs': split_oozie_jobs(bundles.jobs),
+    'has_job_edition_permission': has_job_edition_permission,
+  })
+
+
+@show_oozie_error
+def list_oozie_workflow(request, job_id, coordinator_job_id=None, bundle_job_id=None):
   oozie_workflow = check_job_access_permission(request, job_id)
 
   oozie_coordinator = None
   if coordinator_job_id is not None:
     oozie_coordinator = check_job_access_permission(request, coordinator_job_id)
 
+  oozie_bundle = None
+  if bundle_job_id is not None:
+    oozie_bundle = check_job_access_permission(request, bundle_job_id)
+
   history = History.cross_reference_submission_history(request.user, job_id, coordinator_job_id)
 
   hue_coord = history and history.get_coordinator() or History.get_coordinator_from_config(oozie_workflow.conf_dict)
@@ -156,6 +182,9 @@ def list_oozie_workflow(request, job_id, coordinator_job_id=None):
   if hue_workflow: Job.objects.is_accessible_or_exception(request, hue_workflow.id)
 
   parameters = oozie_workflow.conf_dict.copy()
+  for action in oozie_workflow.actions:
+    action.oozie_coordinator = oozie_coordinator
+    action.oozie_bundle = oozie_bundle
 
   if hue_workflow:
     workflow_graph = hue_workflow.gen_status_graph(oozie_workflow)
@@ -169,7 +198,7 @@ def list_oozie_workflow(request, job_id, coordinator_job_id=None):
       'progress': oozie_workflow.get_progress(),
       'graph': workflow_graph,
       'log': oozie_workflow.log,
-      'actions': massaged_workflow_actions_for_json(oozie_workflow.get_working_actions())
+      'actions': massaged_workflow_actions_for_json(oozie_workflow.get_working_actions(), oozie_coordinator, oozie_bundle)
     }
     return HttpResponse(json.dumps(return_obj).replace('\\\\', '\\'), mimetype="application/json")
 
@@ -177,6 +206,7 @@ def list_oozie_workflow(request, job_id, coordinator_job_id=None):
     'history': history,
     'oozie_workflow': oozie_workflow,
     'oozie_coordinator': oozie_coordinator,
+    'oozie_bundle': oozie_bundle,
     'hue_workflow': hue_workflow,
     'hue_coord': hue_coord,
     'parameters': parameters,
@@ -186,7 +216,7 @@ def list_oozie_workflow(request, job_id, coordinator_job_id=None):
 
 
 @show_oozie_error
-def list_oozie_coordinator(request, job_id):
+def list_oozie_coordinator(request, job_id, bundle_job_id=None):
   oozie_coordinator = check_job_access_permission(request, job_id)
 
   # Cross reference the submission history (if any)
@@ -196,6 +226,10 @@ def list_oozie_coordinator(request, job_id):
   except History.DoesNotExist:
     pass
 
+  oozie_bundle = None
+  if bundle_job_id is not None:
+    oozie_bundle = check_job_access_permission(request, bundle_job_id)
+
   if request.GET.get('format') == 'json':
     return_obj = {
       'id': oozie_coordinator.id,
@@ -204,19 +238,49 @@ def list_oozie_coordinator(request, job_id):
       'nextTime': format_time(oozie_coordinator.nextMaterializedTime),
       'endTime': format_time(oozie_coordinator.endTime),
       'log': oozie_coordinator.log,
-      'actions': massaged_coordinator_actions_for_json(oozie_coordinator)
+      'actions': massaged_coordinator_actions_for_json(oozie_coordinator, oozie_bundle)
     }
     return HttpResponse(json.dumps(return_obj).replace('\\\\', '\\'), mimetype="application/json")
 
   return render('dashboard/list_oozie_coordinator.mako', request, {
     'oozie_coordinator': oozie_coordinator,
     'coordinator': coordinator,
+    'oozie_bundle': oozie_bundle,
     'has_job_edition_permission': has_job_edition_permission,
   })
 
 
 @show_oozie_error
-def list_oozie_workflow_action(request, action):
+def list_oozie_bundle(request, job_id):
+  oozie_bundle = check_job_access_permission(request, job_id)
+
+  # Cross reference the submission history (if any)
+  bundle = None
+  try:
+    bundle = History.objects.get(oozie_job_id=job_id).job.get_full_node()
+  except History.DoesNotExist:
+    pass
+
+  if request.GET.get('format') == 'json':
+    return_obj = {
+      'id': oozie_bundle.id,
+      'status':  oozie_bundle.status,
+      'progress': oozie_bundle.get_progress(),
+      'endTime': format_time(oozie_bundle.endTime),
+      'log': oozie_bundle.log,
+      'actions': massaged_bundle_actions_for_json(oozie_bundle)
+    }
+    return HttpResponse(json.dumps(return_obj).replace('\\\\', '\\'), mimetype="application/json")
+
+  return render('dashboard/list_oozie_bundle.mako', request, {
+    'oozie_bundle': oozie_bundle,
+    'bundle': bundle,
+    'has_job_edition_permission': has_job_edition_permission,
+  })
+
+
+@show_oozie_error
+def list_oozie_workflow_action(request, action, coordinator_job_id=None, bundle_job_id=None):
   try:
     action = get_oozie().get_action(action)
     workflow = check_job_access_permission(request, action.id.split('@')[0])
@@ -224,9 +288,22 @@ def list_oozie_workflow_action(request, action):
     raise PopupException(_("Error accessing Oozie action %s.") % (action,),
                          detail=ex.message)
 
+  oozie_coordinator = None
+  if coordinator_job_id is not None:
+    oozie_coordinator = check_job_access_permission(request, coordinator_job_id)
+
+  oozie_bundle = None
+  if bundle_job_id is not None:
+    oozie_bundle = check_job_access_permission(request, bundle_job_id)
+
+  workflow.oozie_coordinator = oozie_coordinator
+  workflow.oozie_bundle = oozie_bundle
+
   return render('dashboard/list_oozie_workflow_action.mako', request, {
     'action': action,
     'workflow': workflow,
+    'oozie_coordinator': oozie_coordinator,
+    'oozie_bundle': oozie_bundle,
   })
 
 
@@ -335,13 +412,21 @@ def _rerun_coordinator(request, oozie_id, args, params, properties):
                          detail=ex._headers.get('oozie-error-message', ex))
 
 
-def massaged_workflow_actions_for_json(workflow_actions):
+def massaged_workflow_actions_for_json(workflow_actions, oozie_coordinator, oozie_bundle):
   actions = []
+  action_link_params = {}
+
+  if oozie_coordinator is not None:
+    action_link_params['coordinator_job_id'] = oozie_coordinator.id
+  if oozie_bundle is not None:
+    action_link_params['bundle_job_id'] = oozie_bundle.id
+
   for action in workflow_actions:
+    action_link_params.update({'action': action.id})
     massaged_action = {
       'id': action.id,
       'log': action.externalId and reverse('jobbrowser.views.job_single_logs', kwargs={'job': action.externalId}) or '',
-      'url': reverse('oozie:list_oozie_workflow_action', kwargs={'action': action.id}),
+      'url': reverse('oozie:list_oozie_workflow_action', kwargs=action_link_params),
       'name': escapejs(action.name),
       'type': action.type,
       'status': action.status,
@@ -358,14 +443,20 @@ def massaged_workflow_actions_for_json(workflow_actions):
 
   return actions
 
-def massaged_coordinator_actions_for_json(coordinator):
+def massaged_coordinator_actions_for_json(coordinator, oozie_bundle):
   coordinator_id = coordinator.id
   coordinator_actions = coordinator.get_working_actions()
   actions = []
+
+  action_link_params = {}
+  if oozie_bundle is not None:
+    action_link_params['bundle_job_id'] = oozie_bundle.id
+
   for action in coordinator_actions:
+    action_link_params.update({'job_id': action.externalId, 'coordinator_job_id': coordinator_id})
     massaged_action = {
       'id': action.id,
-      'url': action.externalId and reverse('oozie:list_oozie_workflow', kwargs={'job_id': action.externalId, 'coordinator_job_id': coordinator_id}) or '',
+      'url': action.externalId and reverse('oozie:list_oozie_workflow', kwargs=action_link_params) or '',
       'number': action.actionNumber,
       'type': action.type,
       'status': action.status,
@@ -383,6 +474,37 @@ def massaged_coordinator_actions_for_json(coordinator):
 
   return actions
 
+
+def massaged_bundle_actions_for_json(bundle):
+  bundle_actions = bundle.get_working_actions()
+  actions = []
+
+  for action in bundle_actions:
+    massaged_action = {
+      'id': action.coordJobId,
+      'url': action.coordJobId and reverse('oozie:list_oozie_coordinator', kwargs={'job_id': action.coordJobId, 'bundle_job_id': bundle.id}) or '',
+      'name': action.coordJobName,
+      'type': action.type,
+      'status': action.status,
+      'externalId': action.coordExternalId or '-',
+      'frequency': action.frequency,
+      'concurrency': action.concurrency,
+      'pauseTime': action.pauseTime,
+      'user': action.user,
+      'acl': action.acl,
+      'timeOut': action.timeOut,
+      'coordJobPath': action.coordJobPath,
+      'executionPolicy': action.executionPolicy,
+      'startTime': action.startTime,
+      'endTime': action.endTime,
+      'lastAction': action.lastAction
+    }
+
+    actions.insert(0, massaged_action)
+
+  return actions
+
+
 def format_time(st_time):
   if st_time is None:
     return '-'
@@ -397,12 +519,16 @@ def massaged_oozie_jobs_for_json(oozie_jobs, user):
     if job.is_running():
       if job.type == 'Workflow':
         job = get_oozie().get_job(job.id)
-      else:
+      elif job.type == 'Coordinator':
         job = get_oozie().get_coordinator(job.id)
+      else:
+        job = get_oozie().get_bundle(job.id)
 
     massaged_job = {
       'id': job.id,
       'lastModTime': hasattr(job, 'lastModTime') and job.lastModTime and format_time(job.lastModTime) or None,
+      'kickoffTime': hasattr(job, 'kickoffTime') and job.kickoffTime or None,
+      'timeOut': hasattr(job, 'timeOut') and job.timeOut or None,
       'endTime': job.endTime and format_time(job.endTime) or None,
       'status': job.status,
       'isRunning': job.is_running(),
@@ -428,8 +554,10 @@ def split_oozie_jobs(oozie_jobs):
     if job.is_running():
       if job.type == 'Workflow':
         job = get_oozie().get_job(job.id)
-      else:
+      elif job.type == 'Coordinator':
         job = get_oozie().get_coordinator(job.id)
+      else:
+        job = get_oozie().get_bundle(job.id)
       jobs_running.append(job)
     else:
       jobs_completed.append(job)
@@ -452,8 +580,10 @@ def check_job_access_permission(request, job_id):
   if job_id is not None:
     if job_id.endswith('W'):
       get_job = get_oozie().get_job
-    else:
+    elif job_id.endswith('C'):
       get_job = get_oozie().get_coordinator
+    else:
+      get_job = get_oozie().get_bundle
 
     try:
       oozie_job = get_job(job_id)

+ 205 - 6
apps/oozie/src/oozie/views/editor.py

@@ -43,11 +43,11 @@ from oozie.import_workflow import import_workflow as _import_workflow
 from oozie.management.commands import oozie_setup
 from oozie.models import Workflow, History, Coordinator,\
                          Dataset, DataInput, DataOutput,\
-                         ACTION_TYPES
+                         ACTION_TYPES, Bundle, BundledCoordinator
 from oozie.forms import WorkflowForm, CoordinatorForm, DatasetForm,\
   DataInputForm, DataOutputForm, LinkForm,\
   DefaultLinkForm, design_form_by_type, ParameterForm,\
-  ImportWorkflowForm, NodeForm
+  ImportWorkflowForm, NodeForm, BundleForm, BundledCoordinatorForm
 
 
 LOG = logging.getLogger(__name__)
@@ -72,8 +72,6 @@ def list_workflows(request):
 
 
 def list_coordinators(request, workflow_id=None):
-  show_setup_app = True
-
   data = Coordinator.objects
   if workflow_id is not None:
     data = data.filter(workflow__id=workflow_id)
@@ -88,7 +86,22 @@ def list_coordinators(request, workflow_id=None):
   return render('editor/list_coordinators.mako', request, {
     'jobs': list(data),
     'currentuser': request.user,
-    'show_setup_app': show_setup_app,
+  })
+
+
+def list_bundles(request):
+  data = Bundle.objects
+
+  if not SHARE_JOBS.get() and not request.user.is_superuser:
+    data = data.filter(owner=request.user)
+  else:
+    data = data.filter(Q(is_shared=True) | Q(owner=request.user))
+
+  data = data.order_by('-last_modified')
+
+  return render('editor/list_bundles.mako', request, {
+    'jobs': list(data),
+    'currentuser': request.user,
   })
 
 
@@ -492,6 +505,192 @@ def _submit_coordinator(request, coordinator, mapping):
                          detail=ex._headers.get('oozie-error-message', ex))
 
 
+def create_bundle(request):
+  bundle = Bundle(owner=request.user, schema_version='uri:oozie:bundle:0.2')
+
+  if request.method == 'POST':
+    bundle_form = BundleForm(request.POST, instance=bundle)
+
+    if bundle_form.is_valid():
+      bundle = bundle_form.save()
+      return redirect(reverse('oozie:edit_bundle', kwargs={'bundle': bundle.id}))
+    else:
+      request.error(_('Errors on the form: %s') % bundle_form.errors)
+  else:
+    bundle_form = BundleForm(instance=bundle)
+
+  return render('editor/create_bundle.mako', request, {
+    'bundle': bundle,
+    'bundle_form': bundle_form,
+  })
+
+
+@check_job_access_permission()
+@check_job_edition_permission()
+def delete_bundle(request, bundle):
+  if request.method != 'POST':
+    raise PopupException(_('A POST request is required.'))
+
+  bundle.delete()
+  Submission(request.user, bundle, request.fs, {}).remove_deployment_dir()
+  request.info(_('Bundle deleted.'))
+
+  return redirect(reverse('oozie:list_bundles'))
+
+
+@check_job_access_permission()
+@check_job_edition_permission(True)
+def edit_bundle(request, bundle):
+  history = History.objects.filter(submitter=request.user, job=bundle).order_by('-submission_date')
+
+  BundledCoordinatorFormSet = inlineformset_factory(Bundle, BundledCoordinator, form=BundledCoordinatorForm, max_num=0, can_order=False, can_delete=True)
+  bundle_form = BundleForm(instance=bundle)
+
+  if request.method == 'POST':
+    bundle_form = BundleForm(request.POST, instance=bundle)
+    bundled_coordinator_formset = BundledCoordinatorFormSet(request.POST, instance=bundle)
+
+    if bundle_form.is_valid() and bundled_coordinator_formset.is_valid():
+      bundle = bundle_form.save()
+      bundled_coordinator_formset.save()
+
+      request.info(_('Bundle saved.'))
+      return redirect(reverse('oozie:list_bundles'))
+  else:
+    bundle_form = BundleForm(instance=bundle)
+    bundled_coordinator_formset = BundledCoordinatorFormSet(instance=bundle)
+
+  return render('editor/edit_bundle.mako', request, {
+    'bundle': bundle,
+    'bundle_form': bundle_form,
+    'bundled_coordinator_formset': bundled_coordinator_formset,
+    'bundled_coordinator_html_form': get_create_bundled_coordinator_html(request, bundle),
+    'history': history
+  })
+
+
+@check_job_access_permission()
+@check_job_edition_permission(True)
+def create_bundled_coordinator(request, bundle):
+  bundled_coordinator_instance = BundledCoordinator(bundle=bundle)
+
+  response = {'status': -1, 'data': 'None'}
+
+  if request.method == 'POST':
+    bundled_coordinator_form = BundledCoordinatorForm(request.POST, instance=bundled_coordinator_instance, prefix='create-bundled-coordinator')
+
+    if bundled_coordinator_form.is_valid():
+      bundled_coordinator_form.save()
+      response['status'] = 0
+      response['data'] = reverse('oozie:edit_bundle', kwargs={'bundle': bundle.id}) + "#listCoordinators"
+      request.info(_('Coordinator added to the bundle!'))
+  else:
+    bundled_coordinator_form = BundledCoordinatorForm(instance=bundled_coordinator_instance, prefix='create-bundled-coordinator')
+
+  if response['status'] != 0:
+    response['data'] = get_create_bundled_coordinator_html(request, bundle, bundled_coordinator_form=bundled_coordinator_form)
+
+  return HttpResponse(json.dumps(response), mimetype="application/json")
+
+
+def get_create_bundled_coordinator_html(request, bundle, bundled_coordinator_form=None):
+  if bundled_coordinator_form is None:
+    bundled_coordinator_instance = BundledCoordinator(bundle=bundle)
+    bundled_coordinator_form = BundledCoordinatorForm(instance=bundled_coordinator_instance, prefix='create-bundled-coordinator')
+
+  return render('editor/create_bundled_coordinator.mako', request, {
+                            'bundle': bundle,
+                            'bundled_coordinator_form': bundled_coordinator_form,
+                          }, force_template=True).content
+
+
+@check_job_access_permission()
+@check_job_edition_permission(True)
+def edit_bundled_coordinator(request, bundle, bundled_coordinator):
+  bundled_coordinator_instance = BundledCoordinator.objects.get(id=bundled_coordinator) # todo secu
+
+  response = {'status': -1, 'data': 'None'}
+
+  if request.method == 'POST':
+    bundled_coordinator_form = BundledCoordinatorForm(request.POST, instance=bundled_coordinator_instance, prefix='edit-bundled-coordinator')
+
+    if bundled_coordinator_form.is_valid():
+      bundled_coordinator_form.save()
+      response['status'] = 0
+      response['data'] = reverse('oozie:edit_bundle', kwargs={'bundle': bundle.id}) + "#listCoordinators"
+      request.info(_('Bundled coordinator updated!'))
+  else:
+    bundled_coordinator_form = BundledCoordinatorForm(instance=bundled_coordinator_instance, prefix='edit-bundled-coordinator')
+
+  if response['status'] != 0:
+    response['data'] = render('editor/edit_bundled_coordinator.mako', request, {
+                            'bundle': bundle,
+                            'bundled_coordinator_form': bundled_coordinator_form,
+                            'bundled_coordinator_instance': bundled_coordinator_instance,
+                          }, force_template=True).content
+
+  return HttpResponse(json.dumps(response), mimetype="application/json")
+
+
+@check_job_access_permission()
+def clone_bundle(request, bundle):
+  if request.method != 'POST':
+    raise PopupException(_('A POST request is required.'))
+
+  clone = bundle.clone(request.user)
+
+  response = {'url': reverse('oozie:edit_bundle', kwargs={'bundle': clone.id})}
+
+  return HttpResponse(json.dumps(response), mimetype="application/json")
+
+
+@check_job_access_permission()
+def submit_bundle(request, bundle):
+  ParametersFormSet = formset_factory(ParameterForm, extra=0)
+
+  if request.method == 'POST':
+    params_form = ParametersFormSet(request.POST)
+
+    if params_form.is_valid():
+      mapping = dict([(param['name'], param['value']) for param in params_form.cleaned_data])
+      job_id = _submit_bundle(request, bundle, mapping)
+
+      request.info(_('Bundle submitted.'))
+      return redirect(reverse('oozie:list_oozie_bundle', kwargs={'job_id': job_id}))
+    else:
+      request.error(_('Invalid submission form: %s' % params_form.errors))
+  else:
+    parameters = bundle.find_all_parameters()
+    initial_params = ParameterForm.get_initial_params(dict([(param['name'], param['value']) for param in parameters]))
+    params_form = ParametersFormSet(initial=initial_params)
+
+  popup = render('editor/submit_job_popup.mako', request, {
+                 'params_form': params_form,
+                 'action': reverse('oozie:submit_bundle',  kwargs={'bundle': bundle.id})
+                }, force_template=True).content
+  return HttpResponse(json.dumps(popup), mimetype="application/json")
+
+
+def _submit_bundle(request, bundle, properties):
+  try:
+    for bundled in bundle.coordinators.all():
+      wf_dir = Submission(request.user, bundled.coordinator.workflow, request.fs, properties).deploy()
+      properties = {'wf_application_path': request.fs.get_hdfs_path(wf_dir)}
+      coord_dir = Submission(request.user, bundled.coordinator, request.fs, properties).deploy()
+      bundled.coordinator.deployment_dir = coord_dir
+      bundled.coordinator.save() # Does not support concurrent submissions
+
+    submission = Submission(request.user, bundle, request.fs, properties=properties)
+    job_id = submission.run()
+
+    History.objects.create_from_submission(submission)
+
+    return job_id
+  except RestException, ex:
+    raise PopupException(_("Error submitting bundle %s") % (bundle,),
+                         detail=ex._headers.get('oozie-error-message', ex))
+
+
 def list_history(request):
   """
   List the job submission history.
@@ -537,4 +736,4 @@ def setup_app(request):
 
 
 def jasmine(request):
-  return render('editor/jasmine.mako', request, None)
+  return render('editor/jasmine.mako', request, None)

+ 12 - 7
desktop/libs/liboozie/src/liboozie/oozie_api.py

@@ -24,7 +24,7 @@ from desktop.lib.rest.http_client import HttpClient
 from desktop.lib.rest.resource import Resource
 
 from liboozie.types import WorkflowList, CoordinatorList, Coordinator, Workflow,\
-  CoordinatorAction, WorkflowAction
+  CoordinatorAction, WorkflowAction, BundleList, Bundle, BundleAction
 from liboozie.utils import config_gen
 
 # Manage deprecation after HUE-792.
@@ -144,18 +144,20 @@ class OozieApi(object):
     resp = self._root.get('jobs', params)
     if jobtype == 'wf':
       wf_list = WorkflowList(self, resp, filters=kwargs)
-    else:
+    elif jobtype == 'coord':
       wf_list = CoordinatorList(self, resp, filters=kwargs)
+    else:
+      wf_list = BundleList(self, resp, filters=kwargs)
     return wf_list
 
-
   def get_workflows(self, offset=None, cnt=None, **kwargs):
     return self.get_jobs('wf', offset, cnt, **kwargs)
 
-
   def get_coordinators(self, offset=None, cnt=None, **kwargs):
     return self.get_jobs('coord', offset, cnt, **kwargs)
 
+  def get_bundles(self, offset=None, cnt=None, **kwargs):
+    return self.get_jobs('bundle', offset, cnt, **kwargs)
 
   # TODO: make get_job accept any jobid
   def get_job(self, jobid):
@@ -167,12 +169,15 @@ class OozieApi(object):
     wf = Workflow(self, resp)
     return wf
 
-
   def get_coordinator(self, jobid):
     params = self._get_params()
     resp = self._root.get('job/%s' % (jobid,), params)
     return Coordinator(self, resp)
 
+  def get_bundle(self, jobid):
+    params = self._get_params()
+    resp = self._root.get('job/%s' % (jobid,), params)
+    return Bundle(self, resp)
 
   def get_job_definition(self, jobid):
     """
@@ -183,7 +188,6 @@ class OozieApi(object):
     xml = self._root.get('job/%s' % (jobid,), params)
     return xml
 
-
   def get_job_log(self, jobid):
     """
     get_job_log(jobid) -> Log (xml string)
@@ -196,6 +200,8 @@ class OozieApi(object):
   def get_action(self, action_id):
     if 'C@' in action_id:
       Klass = CoordinatorAction
+    elif 'B@' in action_id:
+      Klass = BundleAction
     else:
       Klass = WorkflowAction
     params = self._get_params()
@@ -219,7 +225,6 @@ class OozieApi(object):
 
     return self._root.put('job/%s' % jobid, params,  data=config_gen(properties), contenttype=_XML_CONTENT_TYPE)
 
-
   def submit_workflow(self, application_path, properties=None):
     """
     submit_workflow(application_path, properties=None) -> jobid

+ 172 - 58
desktop/libs/liboozie/src/liboozie/types.py

@@ -52,7 +52,7 @@ class Action(object):
 
   @classmethod
   def create(self, action_class, action_dict):
-    if ControlFlowAction.is_control_flow(action_dict['type']):
+    if ControlFlowAction.is_control_flow(action_dict.get('type')):
       return ControlFlowAction(action_dict)
     else:
       return action_class(action_dict)
@@ -110,6 +110,60 @@ class ControlFlowAction(Action):
     self.conf_dict = {}
 
 
+class WorkflowAction(Action):
+  _ATTRS = [
+    'conf',
+    'consoleUrl',
+    'data',
+    'endTime',
+    'errorCode',
+    'errorMessage',
+    'externalId',
+    'externalStatus',
+    'id',
+    'name',
+    'retries',
+    'startTime',
+    'status',
+    'trackerUri',
+    'transition',
+    'type',
+  ]
+
+  def _fixup(self):
+    """
+    Fixup:
+      - time fields as struct_time
+      - config dict
+    """
+    super(WorkflowAction, self)._fixup()
+
+    if self.startTime:
+      self.startTime = parse_timestamp(self.startTime)
+    if self.endTime:
+      self.endTime = parse_timestamp(self.endTime)
+    if self.retries:
+      self.retries = int(self.retries)
+
+    if self.conf:
+      xml = StringIO(i18n.smart_str(self.conf))
+      self.conf_dict = hadoop.confparse.ConfParse(xml)
+    else:
+      self.conf_dict = {}
+
+    if self.externalId is not None and not re.match('job_.*', self.externalId):
+      self.externalId = None
+
+  def get_absolute_url(self):
+    kwargs = {'action': self.id}
+    if hasattr(self, 'oozie_coordinator') and self.oozie_coordinator:
+      kwargs['coordinator_job_id'] = self.oozie_coordinator.id
+    if hasattr(self, 'oozie_bundle') and self.oozie_bundle:
+      kwargs['bundle_job_id'] = self.oozie_bundle.id
+
+    return reverse('oozie:list_oozie_workflow_action', kwargs=kwargs)
+
+
 class CoordinatorAction(Action):
   _ATTRS = [
     'status',
@@ -154,24 +208,33 @@ class CoordinatorAction(Action):
 
     self.title = ' %s-%s'% (self.actionNumber, format_time(self.nominalTime))
 
-class WorkflowAction(Action):
+
+class BundleAction(Action):
   _ATTRS = [
-    'conf',
-    'consoleUrl',
-    'data',
-    'endTime',
-    'errorCode',
-    'errorMessage',
-    'externalId',
-    'externalStatus',
-    'id',
-    'name',
-    'retries',
-    'startTime',
-    'status',
-    'trackerUri',
-    'transition',
-    'type',
+      'startTime',
+      'actions',
+      'frequency',
+      'concurrency',
+      'pauseTime',
+      'group',
+      'toString',
+      'consoleUrl',
+      'mat_throttling',
+      'status',
+      'conf',
+      'user',
+      'timeOut',
+      'coordJobPath',
+      'timeUnit',
+      'coordJobId',
+      'coordJobName',
+      'nextMaterializedTime',
+      'coordExternalId',
+      'acl',
+      'lastAction',
+      'executionPolicy',
+      'timeZone',
+      'endTime'
   ]
 
   def _fixup(self):
@@ -180,14 +243,10 @@ class WorkflowAction(Action):
       - time fields as struct_time
       - config dict
     """
-    super(WorkflowAction, self)._fixup()
+    super(BundleAction, self)._fixup()
 
-    if self.startTime:
-      self.startTime = parse_timestamp(self.startTime)
-    if self.endTime:
-      self.endTime = parse_timestamp(self.endTime)
-    if self.retries:
-      self.retries = int(self.retries)
+    self.type = 'coord-action'
+    self.name = self.coordJobName
 
     if self.conf:
       xml = StringIO(i18n.smart_str(self.conf))
@@ -195,9 +254,6 @@ class WorkflowAction(Action):
     else:
       self.conf_dict = {}
 
-    if self.externalId is not None and not re.match('job_.*', self.externalId):
-      self.externalId = None
-
 
 class Job(object):
   RUNNING_STATUSES = set(['PREP', 'RUNNING', 'SUSPENDED', 'PREP', # Workflow
@@ -300,6 +356,55 @@ class Job(object):
     return '%s - %s' % (self.id, self.status)
 
 
+class Workflow(Job):
+  _ATTRS = [
+    'actions',
+    'appName',
+    'appPath',
+    'conf',
+    'consoleUrl',
+    'createdTime',
+    'endTime',
+    'externalId',
+    'group',
+    'id',
+    'lastModTime',
+    'run',
+    'startTime',
+    'status',
+    'user',
+    'acl',
+    'parentId'
+  ]
+  ACTION = WorkflowAction
+
+  def _fixup(self):
+    super(Workflow, self)._fixup()
+
+    if self.createdTime:
+      self.createdTime = parse_timestamp(self.createdTime)
+    if self.lastModTime:
+      self.lastModTime = parse_timestamp(self.lastModTime)
+    if self.run:
+      self.run = int(self.run)
+
+  @property
+  def type(self):
+    return 'Workflow'
+
+  def get_absolute_url(self):
+    kwargs = {'job_id': self.id}
+    if hasattr(self, 'oozie_coordinator') and self.oozie_coordinator:
+      kwargs['coordinator_job_id'] = self.oozie_coordinator.id
+    if hasattr(self, 'oozie_bundle') and self.oozie_bundle:
+      kwargs['bundle_job_id'] = self.oozie_bundle.id
+    return reverse('oozie:list_oozie_workflow', kwargs=kwargs)
+
+  def get_progress(self):
+    """How many actions are finished on the total of actions."""
+    return int(sum([action.is_finished() for action in self.actions]) / float(max(len(self.actions), 1)) * 100)
+
+
 class Coordinator(Job):
   _ATTRS = [
     'acl',
@@ -344,8 +449,11 @@ class Coordinator(Job):
   def type(self):
     return 'Coordinator'
 
-  def get_absolute_url(self):
-    return reverse('oozie:list_oozie_coordinator', kwargs={'job_id': self.id})
+  def get_absolute_url(self, oozie_bundle=None):
+    kwargs = {'job_id': self.id}
+    if oozie_bundle:
+      kwargs.update({'bundle_job_id': oozie_bundle.id})
+    return reverse('oozie:list_oozie_coordinator', kwargs=kwargs)
 
   def get_progress(self):
     """How much more time before the final materialization."""
@@ -384,53 +492,54 @@ class Coordinator(Job):
     return result
 
 
-class Workflow(Job):
+class Bundle(Job):
   _ATTRS = [
-    'actions',
-    'appName',
-    'appPath',
+    'status',
+    'toString',
+    'group',
     'conf',
-    'consoleUrl',
+    'bundleJobName',
+    'startTime',
+    'bundleCoordJobs',
+    'kickoffTime',
+    'acl',
+    'bundleJobPath',
     'createdTime',
+    'timeOut',
+    'consoleUrl',
+    'bundleExternalId',
+    'timeUnit',
+    'pauseTime',
+    'bundleJobId',
     'endTime',
-    'externalId',
-    'group',
-    'id',
-    'lastModTime',
-    'run',
-    'startTime',
-    'status',
     'user',
-    'acl',
-    'parentId'
   ]
-  ACTION = WorkflowAction
+
+  ACTION = BundleAction
 
   def _fixup(self):
-    super(Workflow, self)._fixup()
+    self.actions = self.bundleCoordJobs
 
-    if self.createdTime:
-      self.createdTime = parse_timestamp(self.createdTime)
-    if self.lastModTime:
-      self.lastModTime = parse_timestamp(self.lastModTime)
-    if self.run:
-      self.run = int(self.run)
+    super(Bundle, self)._fixup()
+
+    # For when listing/mixing all the jobs together
+    self.id = self.bundleJobId
+    self.appName = self.bundleJobName
 
   @property
   def type(self):
-    return 'Workflow'
+    return 'Bundle'
 
   def get_absolute_url(self):
-    return reverse('oozie:list_oozie_workflow', kwargs={'job_id': self.id})
+    return reverse('oozie:list_oozie_bundle', kwargs={'job_id': self.id})
 
   def get_progress(self):
-    """How many actions are finished on the total of actions."""
-    return int(sum([action.is_finished() for action in self.actions]) / float(max(len(self.actions), 1)) * 100)
+    return 50
 
 
 class JobList(object):
   """
-  Represents a list of Oozie jobs (Workflows or Coordinator).
+  Represents a list of Oozie jobs (Workflows or Coordinators or Bundles).
   """
   _ATTRS = [
     'offset',
@@ -447,7 +556,7 @@ class JobList(object):
     self._api = api
     self.offset = int(json_dict['offset'])
     self.total = int(json_dict['total'])
-    self.jobs = [ klass(self._api, wf_dict) for wf_dict in json_dict[jobs_key] ]
+    self.jobs = [klass(self._api, wf_dict) for wf_dict in json_dict[jobs_key]]
     self.filters = filters
 
 
@@ -460,3 +569,8 @@ class CoordinatorList(JobList):
   def __init__(self, api, json_dict, filters=None):
     super(CoordinatorList, self).__init__(Coordinator, 'coordinatorjobs', api, json_dict, filters)
 
+
+class BundleList(JobList):
+  def __init__(self, api, json_dict, filters=None):
+    super(BundleList, self).__init__(Bundle, 'bundlejobs', api, json_dict, filters)
+

Some files were not shown because too many files changed in this diff