Przeglądaj źródła

HUE-896 [jobsub] Improve jobsub UI/UX and use oozie models

Jobsub application is using oozie models.
It distinguishes its models from oozie models using the 'managed'
boolean field.
Job history and submission is delegated to Oozie.

On the front end, external templates are fetched and placed in
'<script>' tags with node types as their IDs.
Mustache templates are used to populate templates with the proper
knockout bindings and translated strings.
Node type templates are stored in '/jobsub/static/templates'
along with partials that help build the templates.

'Design' and 'designs' are separated on the front end.
'Designs' delegate down to 'design' for job submission, deletion,
cloning, and loading a design.
This enables bulk deletion.

Oozie javascript logic has been placed into separate files for
easier access in the jobsub application.
Javascript models are reused in the jobsub application.
Abraham Elmahrek 13 lat temu
rodzic
commit
890dd6a54f
69 zmienionych plików z 4500 dodań i 3250 usunięć
  1. 85 57
      apps/jobbrowser/src/jobbrowser/tests.py
  2. 3 0
      apps/jobsub/src/jobsub/conf.py
  3. 1 1
      apps/jobsub/src/jobsub/middleware.py
  4. 7 7
      apps/jobsub/src/jobsub/migrations/0001_initial.py
  5. 7 7
      apps/jobsub/src/jobsub/migrations/0002_auto__add_ooziestreamingaction__add_oozieaction__add_oozieworkflow__ad.py
  6. 8 8
      apps/jobsub/src/jobsub/migrations/0003_convertCharFieldtoTextField.py
  7. 3 3
      apps/jobsub/src/jobsub/migrations/0004_hue1_to_hue2.py
  8. 140 0
      apps/jobsub/src/jobsub/migrations/0005_unify_with_oozie.py
  9. 24 0
      apps/jobsub/src/jobsub/models.py
  10. 3 0
      apps/jobsub/src/jobsub/parameterization.py
  11. 690 0
      apps/jobsub/src/jobsub/templates/designs.mako
  12. 0 335
      apps/jobsub/src/jobsub/templates/edit_design.mako
  13. 0 40
      apps/jobsub/src/jobsub/templates/layout.mako
  14. 0 200
      apps/jobsub/src/jobsub/templates/list_designs.mako
  15. 0 101
      apps/jobsub/src/jobsub/templates/list_history.mako
  16. 0 19
      apps/jobsub/src/jobsub/templates/status_bar.mako
  17. 0 52
      apps/jobsub/src/jobsub/templates/workflow-common.xml.mako
  18. 0 58
      apps/jobsub/src/jobsub/templates/workflow-java.xml.mako
  19. 0 49
      apps/jobsub/src/jobsub/templates/workflow-mapreduce.xml.mako
  20. 0 53
      apps/jobsub/src/jobsub/templates/workflow-streaming.xml.mako
  21. 0 241
      apps/jobsub/src/jobsub/templates/workflow.mako
  22. 74 260
      apps/jobsub/src/jobsub/tests.py
  23. 11 17
      apps/jobsub/src/jobsub/urls.py
  24. 114 231
      apps/jobsub/src/jobsub/views.py
  25. 3 0
      apps/jobsub/static/css/jobsub.css
  26. 206 0
      apps/jobsub/static/js/jobsub.js
  27. 327 86
      apps/jobsub/static/js/jobsub.ko.js
  28. 97 0
      apps/jobsub/static/js/jobsub.templates.js
  29. 48 0
      apps/jobsub/static/templates/actions/distcp.html
  30. 76 0
      apps/jobsub/static/templates/actions/email.html
  31. 56 0
      apps/jobsub/static/templates/actions/fs.html
  32. 66 0
      apps/jobsub/static/templates/actions/hive.html
  33. 88 0
      apps/jobsub/static/templates/actions/java.html
  34. 58 0
      apps/jobsub/static/templates/actions/mapreduce.html
  35. 66 0
      apps/jobsub/static/templates/actions/pig.html
  36. 71 0
      apps/jobsub/static/templates/actions/shell.html
  37. 66 0
      apps/jobsub/static/templates/actions/sqoop.html
  38. 71 0
      apps/jobsub/static/templates/actions/ssh.html
  39. 68 0
      apps/jobsub/static/templates/actions/streaming.html
  40. 33 0
      apps/jobsub/static/templates/designs.html
  41. 14 0
      apps/jobsub/static/templates/widgets/filechooser.html
  42. 24 0
      apps/jobsub/static/templates/widgets/params.html
  43. 23 0
      apps/jobsub/static/templates/widgets/prepares.html
  44. 22 0
      apps/jobsub/static/templates/widgets/properties.html
  45. 1 0
      apps/oozie/src/oozie/import_jobsub.py
  46. 304 0
      apps/oozie/src/oozie/migrations/0018_auto__add_field_workflow_managed.py
  47. 23 0
      apps/oozie/src/oozie/models.py
  48. 1 1
      apps/oozie/src/oozie/templates/dashboard/list_oozie_bundle.mako
  49. 1 1
      apps/oozie/src/oozie/templates/dashboard/list_oozie_bundles.mako
  50. 1 1
      apps/oozie/src/oozie/templates/dashboard/list_oozie_coordinator.mako
  51. 1 1
      apps/oozie/src/oozie/templates/dashboard/list_oozie_coordinators.mako
  52. 1 1
      apps/oozie/src/oozie/templates/dashboard/list_oozie_workflow.mako
  53. 1 1
      apps/oozie/src/oozie/templates/dashboard/list_oozie_workflows.mako
  54. 2 2
      apps/oozie/src/oozie/templates/editor/action_utils.mako
  55. 10 3
      apps/oozie/src/oozie/templates/editor/edit_workflow.mako
  56. 5 1
      apps/oozie/src/oozie/tests.py
  57. 30 0
      apps/oozie/src/oozie/utils.py
  58. 2 22
      apps/oozie/src/oozie/views/api.py
  59. 1 2
      apps/oozie/src/oozie/views/dashboard.py
  60. 2 1
      apps/oozie/src/oozie/views/editor.py
  61. 0 0
      apps/oozie/static/js/bundles.utils.js
  62. 39 0
      apps/oozie/static/js/workflow.idgen.js
  63. 2 1388
      apps/oozie/static/js/workflow.js
  64. 131 0
      apps/oozie/static/js/workflow.modal.js
  65. 524 0
      apps/oozie/static/js/workflow.models.js
  66. 230 0
      apps/oozie/static/js/workflow.node-fields.js
  67. 438 0
      apps/oozie/static/js/workflow.node.js
  68. 71 0
      apps/oozie/static/js/workflow.registry.js
  69. 26 0
      apps/oozie/static/js/workflow.utils.js

+ 85 - 57
apps/jobbrowser/src/jobbrowser/tests.py

@@ -23,6 +23,8 @@ import logging
 import time
 import unittest
 
+from django.contrib.auth.models import User
+from django.core.urlresolvers import reverse
 from nose.tools import assert_true, assert_false, assert_equal
 
 from desktop.lib.django_test_util import make_logged_in_client
@@ -30,15 +32,15 @@ from desktop.lib.test_utils import grant_access
 from hadoop import cluster
 from hadoop.conf import YARN_CLUSTERS
 from hadoop.yarn import resource_manager_api, mapreduce_api, history_server_api
-from jobsub.models import OozieDesign, CheckForSetup
 from liboozie.oozie_api_test import OozieServerProvider
+from oozie.models import Workflow
 
 from jobbrowser import models, views
 from jobbrowser.conf import SHARE_JOBS
 
 
 LOG = logging.getLogger(__name__)
-
+_INITIALIZED = False
 
 def test_dots_to_camel_case():
   assert_equal("fooBar", models.dots_to_camel_case("foo.bar"))
@@ -84,17 +86,7 @@ class TestJobBrowserWithHadoop(unittest.TestCase, OozieServerProvider):
       cls.cluster.fs.do_as_superuser(cls.cluster.fs.mkdir, "/tmp")
     cls.cluster.fs.do_as_superuser(cls.cluster.fs.chmod, "/tmp", 0777)
 
-    # Install examples
-    import jobsub.management.commands.jobsub_setup as jobsub_setup
-    if not jobsub_setup.Command().has_been_setup():
-      jobsub_setup.Command().handle()
-
-    cls.sleep_design_id = OozieDesign.objects.get(name='sleep_job').id
 
-  @classmethod
-  def teardown_class(cls):
-    OozieDesign.objects.all().delete()
-    CheckForSetup.objects.all().delete()
 
   def setUp(self):
     TestJobBrowserWithHadoop.user_count += 1
@@ -108,19 +100,52 @@ class TestJobBrowserWithHadoop(unittest.TestCase, OozieServerProvider):
     self.client = make_logged_in_client(username=self.username, is_superuser=False, groupname='test')
     grant_access(self.username, 'test', 'jobsub')
     grant_access(self.username, 'test', 'jobbrowser')
+    grant_access(self.username, 'test', 'oozie')
 
     # Ensure access to MR folder
     self.cluster.fs.do_as_superuser(self.cluster.fs.chmod, '/tmp', 0777, recursive=True)
 
     self.cluster.fs.setuser(self.username)
 
+    self.install_examples()
+    self.design = self.create_design()
+
   def tearDown(self):
     try:
+      Workflow.objects.all().delete()
       # Remove user home directories.
       self.cluster.fs.do_as_superuser(self.cluster.fs.rmtree, self.home_dir)
     except:
       pass
 
+  def create_design(self):
+    response = self.client.post(reverse('jobsub.views.new_design',
+      kwargs={'node_type': 'mapreduce'}),
+      data={'name': 'sleep_job',
+            'description': '',
+            'node_type': 'mapreduce',
+            'jar_path': '/user/hue/oozie/workspaces/lib/hadoop-examples.jar',
+            'prepares': '[]',
+            'files': '[]',
+            'archives': '[]',
+            'job_properties': '[{\"name\":\"mapred.reduce.tasks\",\"value\":\"1\"},{\"name\":\"mapred.mapper.class\",\"value\":\"org.apache.hadoop.examples.SleepJob\"},{\"name\":\"mapred.reducer.class\",\"value\":\"org.apache.hadoop.examples.SleepJob\"},{\"name\":\"mapred.mapoutput.key.class\",\"value\":\"org.apache.hadoop.io.IntWritable\"},{\"name\":\"mapred.mapoutput.value.class\",\"value\":\"org.apache.hadoop.io.NullWritable\"},{\"name\":\"mapred.output.format.class\",\"value\":\"org.apache.hadoop.mapred.lib.NullOutputFormat\"},{\"name\":\"mapred.input.format.class\",\"value\":\"org.apache.hadoop.examples.SleepJob$SleepInputFormat\"},{\"name\":\"mapred.partitioner.class\",\"value\":\"org.apache.hadoop.examples.SleepJob\"},{\"name\":\"mapred.speculative.execution\",\"value\":\"false\"},{\"name\":\"sleep.job.map.sleep.time\",\"value\":\"0\"},{\"name\":\"sleep.job.reduce.sleep.time\",\"value\":\"${REDUCER_SLEEP_TIME}\"}]'},
+      HTTP_X_REQUESTED_WITH='XMLHttpRequest')
+    assert_equal(response.status_code, 200)
+    return Workflow.objects.all()[0]
+
+  def install_examples(self):
+    global _INITIALIZED
+    if _INITIALIZED:
+      return
+
+    self.client.post(reverse('oozie:setup_app'))
+    self.cluster.fs.do_as_user(self.username, self.cluster.fs.create_home_dir, self.home_dir)
+    self.cluster.fs.do_as_superuser(self.cluster.fs.chmod, self.home_dir, 0777, True)
+    hue = User.objects.create_user('hue', 'hue' + '@localhost', 'hue')
+    Workflow.objects.update(owner=hue)
+
+    _INITIALIZED = True
+
   def test_uncommon_views(self):
     """
     These views exist, but tend not to be ever called,
@@ -145,25 +170,34 @@ class TestJobBrowserWithHadoop(unittest.TestCase, OozieServerProvider):
     except:
         # rmtree probably failed here.
         pass
-    response = self.client.post('/jobsub/new_design/mapreduce', {
-        'wf-name': ['test_failed_jobs-1'],
-        'wf-description': ['description test_failed_jobs-1'],
-        'action-args': [''],
-        'action-jar_path': ['/user/hue/jobsub/examples/hadoop-examples.jar'],
-        'action-archives': ['[]'],
-        'action-job_properties': ['[{"name":"mapred.input.dir","value":"%s"},\
+    response = self.client.post(reverse('jobsub.views.new_design', kwargs={'node_type': 'mapreduce'}), {
+        'name': ['test_failed_jobs-1'],
+        'description': ['description test_failed_jobs-1'],
+        'args': '',
+        'jar_path': '/user/hue/oozie/workspaces/lib/hadoop-examples.jar',
+        'prepares': '[]',
+        'archives': '[]',
+        'files': '[]',
+        'job_properties': ['[{"name":"mapred.input.dir","value":"%s"},\
             {"name":"mapred.output.dir","value":"%s"},\
             {"name":"mapred.mapper.class","value":"org.apache.hadoop.mapred.lib.dne"},\
             {"name":"mapred.combiner.class","value":"org.apache.hadoop.mapred.lib.dne"},\
-            {"name":"mapred.reducer.class","value":"org.apache.hadoop.mapred.lib.dne"}]' % (INPUT_DIR, OUTPUT_DIR)],
-        'action-files': ['[]']}, follow=True)
-    designs = json.loads(response.context['designs'])
+            {"name":"mapred.reducer.class","value":"org.apache.hadoop.mapred.lib.dne"}]' % (INPUT_DIR, OUTPUT_DIR)]
+        }, HTTP_X_REQUESTED_WITH='XMLHttpRequest', follow=True)
 
     # Submit the job
-    design_id = designs[0]['id']
-    response = self.client.post("/jobsub/submit_design/%d" % design_id, follow=True)
-    oozie_jobid = response.context['jobid']
-    OozieServerProvider.wait_until_completion(oozie_jobid, timeout=500, step=1)
+    design_dict = json.loads(response.content)
+    design_id = int(design_dict['id'][0])
+    response = self.client.post(reverse('oozie:submit_workflow',
+                                args=[design_id]),
+                                data={u'form-MAX_NUM_FORMS': [u''],
+                                      u'form-INITIAL_FORMS': [u'1'],
+                                      u'form-0-name': [u'REDUCER_SLEEP_TIME'],
+                                      u'form-0-value': [u'1'],
+                                      u'form-TOTAL_FORMS': [u'1']},
+                                follow=True)
+    oozie_jobid = response.context['oozie_workflow'].id
+    job = OozieServerProvider.wait_until_completion(oozie_jobid, timeout=120, step=1)
     hadoop_job_id = get_hadoop_job_id(self.oozie, oozie_jobid, 1)
     hadoop_job_id_short = views.get_shorter_id(hadoop_job_id)
 
@@ -203,20 +237,17 @@ class TestJobBrowserWithHadoop(unittest.TestCase, OozieServerProvider):
     """
     Test job in kill state.
     """
-    # Clone design
-    assert_equal(0, OozieDesign.objects.filter(owner__username=self.username).count())
-    self.client.post('/jobsub/clone_design/%d' % self.sleep_design_id)
-    assert_equal(1, OozieDesign.objects.filter(owner__username=self.username).count())
-
     # Run the sleep example, since it doesn't require user home directory
-    design_id = OozieDesign.objects.get(owner__username=self.username).id
-    response = self.client.post("/jobsub/submit_design/%d" % (design_id,),
-      dict(map_sleep_time=1,
-           num_maps=1,
-           num_reduces=1,
-           reduce_sleep_time=1),
-      follow=True)
-    oozie_jobid = response.context['jobid']
+    design_id = self.design.id
+    response = self.client.post(reverse('oozie:submit_workflow',
+                                args=[self.design.id]),
+                                data={u'form-MAX_NUM_FORMS': [u''],
+                                      u'form-INITIAL_FORMS': [u'1'],
+                                      u'form-0-name': [u'REDUCER_SLEEP_TIME'],
+                                      u'form-0-value': [u'1'],
+                                      u'form-TOTAL_FORMS': [u'1']},
+                                follow=True)
+    oozie_jobid = response.context['oozie_workflow'].id
 
     # Wait for a job to be created and fetch job ID
     hadoop_job_id = get_hadoop_job_id(self.oozie, oozie_jobid, 1)
@@ -292,21 +323,18 @@ class TestJobBrowserWithHadoop(unittest.TestCase, OozieServerProvider):
     The status of the jobs should be the same as the status reported back by oozie.
     In this case, all jobs should succeed.
     """
-    # Clone design
-    assert_equal(0, OozieDesign.objects.filter(owner__username=self.username).count())
-    self.client.post('/jobsub/clone_design/%d' % self.sleep_design_id)
-    assert_equal(1, OozieDesign.objects.filter(owner__username=self.username).count())
-
     # Run the sleep example, since it doesn't require user home directory
-    design_id = OozieDesign.objects.get(owner__username=self.username).id
-    response = self.client.post("/jobsub/submit_design/%d" % (design_id,),
-      dict(map_sleep_time=1,
-           num_maps=1,
-           num_reduces=1,
-           reduce_sleep_time=1),
-      follow=True)
-    oozie_jobid = response.context['jobid']
-    job = OozieServerProvider.wait_until_completion(oozie_jobid, timeout=120, step=1)
+    design_id = self.design.id
+    response = self.client.post(reverse('oozie:submit_workflow',
+                                args=[design_id]),
+                                data={u'form-MAX_NUM_FORMS': [u''],
+                                      u'form-INITIAL_FORMS': [u'1'],
+                                      u'form-0-name': [u'REDUCER_SLEEP_TIME'],
+                                      u'form-0-value': [u'1'],
+                                      u'form-TOTAL_FORMS': [u'1']},
+                                follow=True)
+    oozie_jobid = response.context['oozie_workflow'].id
+    OozieServerProvider.wait_until_completion(oozie_jobid, timeout=120, step=1)
     hadoop_job_id = get_hadoop_job_id(self.oozie, oozie_jobid, 1)
     hadoop_job_id_short = views.get_shorter_id(hadoop_job_id)
 
@@ -371,10 +399,10 @@ class TestJobBrowserWithHadoop(unittest.TestCase, OozieServerProvider):
     # We can't just check the complete contents of the python map because the
     # SLOTS_MILLIS_* entries have a variable number of milliseconds from
     # run-to-run.
-    assert_equal(response.context['job'].counters['org.apache.hadoop.mapreduce.JobCounter']['counters']['TOTAL_LAUNCHED_MAPS']['total'], 1)
-    assert_equal(response.context['job'].counters['org.apache.hadoop.mapreduce.JobCounter']['counters']['TOTAL_LAUNCHED_REDUCES']['total'], 1)
-    assert_equal(response.context['job'].counters['org.apache.hadoop.mapreduce.JobCounter']['counters']['FALLOW_SLOTS_MILLIS_MAPS']['total'], 0)
-    assert_equal(response.context['job'].counters['org.apache.hadoop.mapreduce.JobCounter']['counters']['FALLOW_SLOTS_MILLIS_REDUCES']['total'], 0)
+    assert_equal(response.context['job'].counters['org.apache.hadoop.mapreduce.JobCounter']['counters']['TOTAL_LAUNCHED_MAPS']['total'], 2L)
+    assert_equal(response.context['job'].counters['org.apache.hadoop.mapreduce.JobCounter']['counters']['TOTAL_LAUNCHED_REDUCES']['total'], 1L)
+    assert_equal(response.context['job'].counters['org.apache.hadoop.mapreduce.JobCounter']['counters']['FALLOW_SLOTS_MILLIS_MAPS']['total'], 0L)
+    assert_equal(response.context['job'].counters['org.apache.hadoop.mapreduce.JobCounter']['counters']['FALLOW_SLOTS_MILLIS_REDUCES']['total'], 0L)
     assert_true(response.context['job'].counters['org.apache.hadoop.mapreduce.JobCounter']['counters']['SLOTS_MILLIS_MAPS']['total'] > 0)
     assert_true(response.context['job'].counters['org.apache.hadoop.mapreduce.JobCounter']['counters']['SLOTS_MILLIS_REDUCES']['total'] > 0)
 

+ 3 - 0
apps/jobsub/src/jobsub/conf.py

@@ -22,6 +22,9 @@ from desktop.lib.conf import Config, coerce_bool
 from desktop.lib import paths
 from django.utils.translation import ugettext_lazy as _
 
+
+# Deprecated! To remove in Hue 3.
+# All of the config is now in Oozie app.
 REMOTE_DATA_DIR = Config(
   key="remote_data_dir",
   default="/user/hue/jobsub",

+ 1 - 1
apps/jobsub/src/jobsub/middleware.py

@@ -24,7 +24,7 @@ class SubmissionErrorRecastMiddleware(object):
   When this middleware sees a SubmissionError,
   it adds a response_data field to it.
 
-  We do this instead of "monkey-patching" a response_data 
+  We do this instead of "monkey-patching" a response_data
   property into SubmissionError.
   """
   def process_exception(self, request, exception):

+ 7 - 7
apps/jobsub/src/jobsub/migrations/0001_initial.py

@@ -21,7 +21,7 @@ from south.v2 import SchemaMigration
 from django.db import models
 
 class Migration(SchemaMigration):
-    
+
     def forwards(self, orm):
         # Adding model 'JobDesign'
         db.create_table('jobsub_jobdesign', (
@@ -41,10 +41,10 @@ class Migration(SchemaMigration):
             ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
         ))
         db.send_create_signal('jobsub', ['CheckForSetup'])
-    
-    
+
+
     def backwards(self, orm):
-        
+
         # Deleting model 'ServerSubmissionState'
         db.delete_table('jobsub_serversubmissionstate')
 
@@ -56,8 +56,8 @@ class Migration(SchemaMigration):
 
         # Deleting model 'CheckForSetup'
         db.delete_table('jobsub_checkforsetup')
-    
-    
+
+
     models = {
         'auth.group': {
             'Meta': {'object_name': 'Group'},
@@ -111,5 +111,5 @@ class Migration(SchemaMigration):
             'type': ('django.db.models.fields.CharField', [], {'max_length': '128'})
         }
     }
-    
+
     complete_apps = ['jobsub']

+ 7 - 7
apps/jobsub/src/jobsub/migrations/0002_auto__add_ooziestreamingaction__add_oozieaction__add_oozieworkflow__ad.py

@@ -33,13 +33,13 @@ from jobsub.models import JobDesign, OozieJavaAction, OozieStreamingAction, Oozi
 LOG = logging.getLogger(__name__)
 
 class Migration(SchemaMigration):
-    
+
     def forwards(self, orm):
         """
         Added custom transaction processing for transactional DBMS.
         If a DDL operation fails, the entire transaction fails and all future commands are ignored.
         """
-        
+
         # Adding model 'OozieStreamingAction'
         db.create_table('jobsub_ooziestreamingaction', (
             ('oozieaction_ptr', self.gf('django.db.models.fields.related.OneToOneField')(to=orm['jobsub.OozieAction'], unique=True, primary_key=True)),
@@ -104,7 +104,7 @@ class Migration(SchemaMigration):
 
         # Adding field 'CheckForSetup.setup_level'
         db.add_column('jobsub_checkforsetup', 'setup_level', self.gf('django.db.models.fields.IntegerField')(default=0), keep_default=False)
-    
+
         # The next sequence may fail... so they should have their own transactions.
         db.commit_transaction()
 
@@ -129,7 +129,7 @@ class Migration(SchemaMigration):
         db.start_transaction()
 
     def backwards(self, orm):
-        
+
         # Deleting model 'OozieStreamingAction'
         db.delete_table('jobsub_ooziestreamingaction')
 
@@ -150,8 +150,8 @@ class Migration(SchemaMigration):
 
         # Deleting field 'CheckForSetup.setup_level'
         db.delete_column('jobsub_checkforsetup', 'setup_level')
-    
-    
+
+
     models = {
         'auth.group': {
             'Meta': {'object_name': 'Group'},
@@ -256,6 +256,6 @@ class Migration(SchemaMigration):
             'root_action': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['jobsub.OozieAction']"})
         }
     }
-    
+
     complete_apps = ['jobsub']
 

+ 8 - 8
apps/jobsub/src/jobsub/migrations/0003_convertCharFieldtoTextField.py

@@ -5,9 +5,9 @@ from south.v2 import SchemaMigration
 from django.db import models
 
 class Migration(SchemaMigration):
-    
+
     def forwards(self, orm):
-        
+
         # Changing field 'OozieStreamingAction.job_properties'
         db.alter_column('jobsub_ooziestreamingaction', 'job_properties', self.gf('django.db.models.fields.TextField')())
 
@@ -16,10 +16,10 @@ class Migration(SchemaMigration):
 
         # Changing field 'OozieJavaAction.job_properties'
         db.alter_column('jobsub_ooziejavaaction', 'job_properties', self.gf('django.db.models.fields.TextField')())
-    
-    
+
+
     def backwards(self, orm):
-        
+
         # Changing field 'OozieStreamingAction.job_properties'
         db.alter_column('jobsub_ooziestreamingaction', 'job_properties', self.gf('django.db.models.fields.CharField')(max_length=32768))
 
@@ -28,8 +28,8 @@ class Migration(SchemaMigration):
 
         # Changing field 'OozieJavaAction.job_properties'
         db.alter_column('jobsub_ooziejavaaction', 'job_properties', self.gf('django.db.models.fields.CharField')(max_length=32768))
-    
-    
+
+
     models = {
         'auth.group': {
             'Meta': {'object_name': 'Group'},
@@ -134,5 +134,5 @@ class Migration(SchemaMigration):
             'reducer': ('django.db.models.fields.CharField', [], {'max_length': '512'})
         }
     }
-    
+
     complete_apps = ['jobsub']

+ 3 - 3
apps/jobsub/src/jobsub/migrations/0004_hue1_to_hue2.py

@@ -33,7 +33,7 @@ from jobsub.models import JobDesign, OozieJavaAction, OozieStreamingAction, Oozi
 LOG = logging.getLogger(__name__)
 
 class Migration(DataMigration):
-    
+
     def forwards(self, orm):
         # Since this logic was moved from the 0002 migration,
         # need to make sure this logic hasn't been executed in the past.
@@ -46,7 +46,7 @@ class Migration(DataMigration):
 
     def backwards(self, orm):
         pass
-    
+
     models = {
         'auth.group': {
             'Meta': {'object_name': 'Group'},
@@ -151,7 +151,7 @@ class Migration(DataMigration):
             'reducer': ('django.db.models.fields.CharField', [], {'max_length': '512'})
         }
     }
-    
+
     complete_apps = ['jobsub']
 
 #

+ 140 - 0
apps/jobsub/src/jobsub/migrations/0005_unify_with_oozie.py

@@ -0,0 +1,140 @@
+# encoding: utf-8
+import datetime
+from south.db import db
+from south.v2 import DataMigration
+from django.db import models
+from oozie.import_jobsub import convert_jobsub_design
+from oozie.models import Workflow
+
+
+class Migration(DataMigration):
+    def forwards(self, orm):
+        """ Find every design and move them into Oozie. """
+        for design in orm.JobDesign.objects.all():
+            action = convert_jobsub_design(design)
+
+            if not action:
+                raise RuntimeException(_("Cannot convert %s design into an Oozie action.") % design.name)
+
+            workflow = Workflow.objects.new_workflow(request.user)
+            workflow.name = action.name
+            workflow.owner = design.owner
+            workflow.description = design.description
+            # Inform oozie to not manage this workflow.
+            workflow.managed = False
+            action.workflow = workflow
+
+            workflow.save()
+            action.save()
+
+
+    def backwards(self, orm):
+        """ Cannot migrate backwards once migrated forwards. """
+        raise RuntimeException(_("Cannot backwards migrate this change."))
+
+    models = {
+        'auth.group': {
+            'Meta': {'object_name': 'Group'},
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'name': ('django.db.models.fields.CharField', [], {'max_length': '80', 'unique': 'True'}),
+            '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', [], {'max_length': '30', 'unique': 'True'})
+        },
+        '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'})
+        },
+        'jobsub.checkforsetup': {
+            'Meta': {'object_name': 'CheckForSetup'},
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'setup_level': ('django.db.models.fields.IntegerField', [], {'default': '0'}),
+            'setup_run': ('django.db.models.fields.BooleanField', [], {'default': 'False', 'blank': 'True'})
+        },
+        'jobsub.jobdesign': {
+            'Meta': {'object_name': 'JobDesign'},
+            'data': ('django.db.models.fields.CharField', [], {'max_length': '4096'}),
+            'description': ('django.db.models.fields.CharField', [], {'max_length': '1024'}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'last_modified': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'blank': 'True'}),
+            'name': ('django.db.models.fields.CharField', [], {'max_length': '40'}),
+            'owner': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}),
+            'type': ('django.db.models.fields.CharField', [], {'max_length': '128'})
+        },
+        'jobsub.jobhistory': {
+            'Meta': {'object_name': 'JobHistory'},
+            'design': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['jobsub.OozieDesign']"}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'job_id': ('django.db.models.fields.CharField', [], {'max_length': '128'}),
+            'owner': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}),
+            'submission_date': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'blank': 'True'})
+        },
+        'jobsub.oozieaction': {
+            'Meta': {'object_name': 'OozieAction'},
+            'action_type': ('django.db.models.fields.CharField', [], {'max_length': '64'}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'})
+        },
+        'jobsub.ooziedesign': {
+            'Meta': {'object_name': 'OozieDesign'},
+            'description': ('django.db.models.fields.CharField', [], {'max_length': '1024', 'blank': 'True'}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'last_modified': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'blank': 'True'}),
+            'name': ('django.db.models.fields.CharField', [], {'max_length': '64'}),
+            'owner': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}),
+            'root_action': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['jobsub.OozieAction']"})
+        },
+        'jobsub.ooziejavaaction': {
+            'Meta': {'object_name': 'OozieJavaAction', '_ormbases': ['jobsub.OozieAction']},
+            'archives': ('django.db.models.fields.CharField', [], {'default': "'[]'", 'max_length': '512'}),
+            'args': ('django.db.models.fields.CharField', [], {'max_length': '4096', 'blank': 'True'}),
+            'files': ('django.db.models.fields.CharField', [], {'default': "'[]'", 'max_length': '512'}),
+            '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': "'[]'"}),
+            'main_class': ('django.db.models.fields.CharField', [], {'max_length': '256'}),
+            'oozieaction_ptr': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['jobsub.OozieAction']", 'unique': 'True', 'primary_key': 'True'})
+        },
+        'jobsub.ooziemapreduceaction': {
+            'Meta': {'object_name': 'OozieMapreduceAction', '_ormbases': ['jobsub.OozieAction']},
+            'archives': ('django.db.models.fields.CharField', [], {'default': "'[]'", 'max_length': '512'}),
+            'files': ('django.db.models.fields.CharField', [], {'default': "'[]'", 'max_length': '512'}),
+            'jar_path': ('django.db.models.fields.CharField', [], {'max_length': '512'}),
+            'job_properties': ('django.db.models.fields.TextField', [], {'default': "'[]'"}),
+            'oozieaction_ptr': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['jobsub.OozieAction']", 'unique': 'True', 'primary_key': 'True'})
+        },
+        'jobsub.ooziestreamingaction': {
+            'Meta': {'object_name': 'OozieStreamingAction', '_ormbases': ['jobsub.OozieAction']},
+            'archives': ('django.db.models.fields.CharField', [], {'default': "'[]'", 'max_length': '512'}),
+            'files': ('django.db.models.fields.CharField', [], {'default': "'[]'", 'max_length': '512'}),
+            'job_properties': ('django.db.models.fields.TextField', [], {'default': "'[]'"}),
+            'mapper': ('django.db.models.fields.CharField', [], {'max_length': '512'}),
+            'oozieaction_ptr': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['jobsub.OozieAction']", 'unique': 'True', 'primary_key': 'True'}),
+            'reducer': ('django.db.models.fields.CharField', [], {'max_length': '512'})
+        }
+    }
+
+    complete_apps = ['jobsub']

+ 24 - 0
apps/jobsub/src/jobsub/models.py

@@ -91,6 +91,10 @@ PATH_MAX = 512
 
 class OozieAction(models.Model):
   """
+  DEPRECATED!!!
+      This is the old Hue 2.0/2.1 job design model. In Hue 2.2 and newer,
+      Oozie models are used.
+
   The OozieAction model is an abstract base class. All concrete actions
   derive from it. And it provides something for the OozieDesign to
   reference. See
@@ -119,6 +123,10 @@ class OozieAction(models.Model):
 
 class OozieDesign(models.Model):
   """
+  DEPRECATED!!!
+      This is the old Hue 2.0/2.1 job design model. In Hue 2.2 and newer,
+      Oozie models are used.
+
   Contains information about all (Oozie) designs. Specific action info are
   stored in the Oozie*Action models.
   """
@@ -174,6 +182,10 @@ class OozieDesign(models.Model):
 
 class OozieMapreduceAction(OozieAction):
   """
+  DEPRECATED!!!
+      This is the old Hue 2.0/2.1 job design model. In Hue 2.2 and newer,
+      Oozie models are used.
+
   Stores MR actions
   """
   PARAM_FIELDS = ('files', 'archives', 'job_properties', 'jar_path')
@@ -193,6 +205,10 @@ class OozieMapreduceAction(OozieAction):
 
 class OozieStreamingAction(OozieAction):
   """
+  DEPRECATED!!!
+      This is the old Hue 2.0/2.1 job design model. In Hue 2.2 and newer,
+      Oozie models are used.
+
   This is still an MR action from Oozie's perspective. But the data modeling is
   slightly different.
 
@@ -214,6 +230,10 @@ class OozieStreamingAction(OozieAction):
 
 class OozieJavaAction(OozieAction):
   """
+  DEPRECATED!!!
+      This is the old Hue 2.0/2.1 job design model. In Hue 2.2 and newer,
+      Oozie models are used.
+
   Definition of Java actions
   """
   PARAM_FIELDS = ('files', 'archives', 'jar_path', 'main_class', 'args',
@@ -236,6 +256,10 @@ class OozieJavaAction(OozieAction):
 
 class JobHistory(models.Model):
   """
+  DEPRECATED!!!
+      This is the old Hue 2.0/2.1 job design model. In Hue 2.2 and newer,
+      Oozie models are used.
+
   Contains informatin on submitted jobs/workflows.
   """
   owner = models.ForeignKey(User)

+ 3 - 0
apps/jobsub/src/jobsub/parameterization.py

@@ -31,6 +31,9 @@ and explicitly only supports two variables.
 TODO(philip): This also needs methods for simply
 indicating which variables need substitution, to
 prompt the user only for those.
+
+DEPRECATED!!!
+Jobsub uses oozie models now.
 """
 
 import logging

+ 690 - 0
apps/jobsub/src/jobsub/templates/designs.mako

@@ -0,0 +1,690 @@
+## 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.
+
+<%!
+import cgi
+import urllib
+import time
+from desktop.views import commonheader, commonfooter
+from django.utils.translation import ugettext as _
+%>
+
+<%namespace name="actionbar" file="actionbar.mako" />
+
+${ commonheader(_('Job Designer'), "jobsub", user, "60px") | n,unicode }
+
+<link rel="stylesheet" href="/jobsub/static/css/jobsub.css">
+
+<script src="/static/ext/js/mustache.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>
+<script src="/static/ext/js/datatables-paging-0.1.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/knockout.mapping-2.3.2.js" type="text/javascript" charset="utf-8"></script>
+<script src="/static/ext/js/moment.min.js" type="text/javascript" charset="utf-8"></script>
+<script src="/oozie/static/js/workflow.models.js" type="text/javascript" charset="utf-8"></script>
+<script src="/oozie/static/js/workflow.node-fields.js" type="text/javascript" charset="utf-8"></script>
+<script src="/jobsub/static/js/jobsub.templates.js" type="text/javascript" charset="utf-8"></script>
+<script src="/jobsub/static/js/jobsub.ko.js" type="text/javascript" charset="utf-8"></script>
+<script src="/jobsub/static/js/jobsub.js" type="text/javascript" charset="utf-8"></script>
+
+<div class="container-fluid">
+  <h1>${_('Job Designs')}</h1>
+
+  <%actionbar:render>
+    <%def name="actions()">
+      <button id="submit-design" class="btn" title="${_('Submit')}" data-bind="enable: selectedDesignObjects().length == 1"><i class="icon-play"></i> ${_('Submit')}</button>
+      <button id="edit-design" class="btn" title="${_('Edit')}" data-bind="enable: selectedDesignObjects().length == 1"><i class="icon-pencil"></i> ${_('Edit')}</button>
+      <button id="delete-designs" class="btn" title="${_('Delete')}" data-bind="enable: selectedDesignObjects().length > 0"><i class="icon-trash"></i> ${_('Delete')}</button>
+      <button id="clone-designs" class="btn" title="${_('Clone')}" data-bind="click: cloneDesigns, enable: selectedDesignObjects().length > 0"><i class="icon-share"></i> ${_('Clone')}</button>
+    </%def>
+
+    <%def name="creation()">
+        <div id="new-action-dropdown" class="btn-group" style="display: inline">
+          <a href="#" class="btn new-action-link dropdown-toggle" title="${_('New Action')}" data-toggle="dropdown">
+            <i class="icon-plus-sign"></i> ${_('New Action')}
+            <span class="caret"></span>
+          </a>
+          <ul class="dropdown-menu" style="top: auto">
+            <li>
+              <a href="#new-design/mapreduce" class="new-node-link" title="${_('Create MapReduce Design')}" rel="tooltip"><i class="icon-plus-sign"></i> ${_('MapReduce')}</a>
+            </li>
+            <li>
+              <a href="#new-design/java" class="new-node-link" title="${_('Create Java Design')}" rel="tooltip"><i class="icon-plus-sign"></i> ${_('Java')}</a>
+            </li>
+            <li>
+              <a href="#new-design/streaming" class="new-node-link" title="${_('Create Streaming Design')}" rel="tooltip"><i class="icon-plus-sign"></i> ${_('Streaming')}</a>
+            </li>
+            <li>
+              <a href="#new-design/hive" class="new-node-link" title="${_('Create Hive Design')}" rel="tooltip"><i class="icon-plus-sign"></i> ${_('Hive')}</a>
+            </li>
+            <li>
+              <a href="#new-design/pig" class="new-node-link" title="${_('Create Pig Design')}" rel="tooltip"><i class="icon-plus-sign"></i> ${_('Pig')}</a>
+            </li>
+            <li>
+              <a href="#new-design/sqoop" class="new-node-link" title="${_('Create Sqoop Design')}" rel="tooltip"><i class="icon-plus-sign"></i> ${_('Sqoop')}</a>
+            </li>
+            <li>
+              <a href="#new-design/fs" class="new-node-link" title="${_('Create FS Design')}" rel="tooltip"><i class="icon-plus-sign"></i> ${_('FS')}</a>
+            </li>
+            <li>
+              <a href="#new-design/ssh" class="new-node-link" title="${_('Create SSH Design')}" rel="tooltip"><i class="icon-plus-sign"></i> ${_('SSH')}</a>
+            </li>
+            <li>
+              <a href="#new-design/shell" class="new-node-link" title="${_('Create Shell Design')}" rel="tooltip"><i class="icon-plus-sign"></i> ${_('Shell')}</a>
+            </li>
+            <li>
+              <a href="#new-design/email" class="new-node-link" title="${_('Create Email Design')}" rel="tooltip"><i class="icon-plus-sign"></i> ${_('Email')}</a>
+            </li>
+            <li>
+              <a href="#new-design/distcp" class="new-node-link" title="${_('Create DistCP Design')}" rel="tooltip"><i class="icon-plus-sign"></i> ${_('DistCP')}</a>
+            </li>
+          </ul>
+        </div>
+    </%def>
+  </%actionbar:render>
+
+  <div id="design" class="section" data-bind="template: {name: temporary().template(), data: temporary().design(), if: temporary().design()}"></div>
+
+  <div id="list-designs" class="section">
+    <table id="designTable" class="table table-condensed datatables">
+      <thead>
+        <tr>
+          <th width="1%">
+            <div id="selectAll" data-bind="click: toggleSelectAll, css: {hueCheckbox: true, 'icon-ok': selectedDesignObjects().length == designs().length}"></div>
+          </th>
+          <th>${_('Name')}</th>
+          <th>${_('Description')}</th>
+          <th>${_('Owner')}</th>
+          <th>${_('Type')}</th>
+          <th>${_('Last modified')}</th>
+        </tr>
+      </thead>
+      <tbody id="designs" data-bind="template: {name: 'designTemplate', foreach: designs}">
+
+      </tbody>
+    </table>
+  </div>
+
+</div>
+
+<script id="designTemplate" type="text/html">
+  <tr style="cursor: pointer" data-bind="with: design">
+    <td data-row-selector-exclude="true" data-bind="click: function(data, event) {$root.toggleSelect.call($root, $index());}" class="center" style="cursor: default">
+      <div class="hueCheckbox savedCheck" data-row-selector-exclude="true" data-bind="css: {hueCheckbox: name != '..', 'icon-ok': $parent.selected()}"></div>
+    </td>
+    <td data-bind="click: function(data, event) { window.location = '#edit-design/' + $index() }, text: name"></td>
+    <td data-bind="click: function(data, event) { window.location = '#edit-design/' + $index() }, text: description"></td>
+    <td data-bind="click: function(data, event) { window.location = '#edit-design/' + $index() }, text: owner"></td>
+    <td data-bind="click: function(data, event) { window.location = '#edit-design/' + $index() }, text: node_type"></td>
+    <td data-bind="click: function(data, event) { window.location = '#edit-design/' + $index() }, text: new Date(last_modified() * 1000).format('%B %d, %Y %I:%M %p'), attr: { 'data-sort-value': last_modified() }"></td>
+  </tr>
+</script>
+
+<div id="submitWf" class="modal hide fade"></div>
+
+<div id="deleteWf" class="modal hide fade">
+  <form id="deleteWfForm" action="#" method="POST" style="margin:0">
+    <div class="modal-header">
+      <a href="#" class="close" data-dismiss="modal">&times;</a>
+      <h3 id="deleteWfMessage">${_('Delete the selected designs?')}</h3>
+    </div>
+    <div class="modal-footer">
+      <a href="#" class="btn" data-dismiss="modal">${_('No')}</a>
+      <input type="submit" class="btn btn-danger" value="${_('Yes')}" data-dismiss="modal" data-bind="click: deleteDesigns" />
+    </div>
+  </form>
+</div>
+
+<div id="chooseFile" class="modal hide fade">
+  <div class="modal-header">
+    <a href="#" class="close" data-dismiss="modal">&times;</a>
+    <h3>${_('Choose a file')}</h3>
+  </div>
+  <div class="modal-body">
+    <div id="fileChooserModal">
+    </div>
+  </div>
+  <div class="modal-footer">
+  </div>
+</div>
+
+<script type="text/javascript" charset="utf-8">
+$(document).bind('initialize.designs', function() {
+  var designTable, viewModel;
+
+  $("#filterInput").keyup(function() {
+      if (designTable != null){
+          designTable.fnFilter($(this).val());
+      }
+  });
+
+  designTable = $('#designTable').dataTable( {
+    "sPaginationType": "bootstrap",
+    "bLengthChange": false,
+    "sDom": "<'row'r>t<'row'<'span8'i><''p>>",
+    "bDestroy": true,
+    "aoColumns": [
+      { "bSortable": false },
+      null,
+      null,
+      null,
+      null,
+      { "sSortDataType": "dom-sort-value", "sType": "numeric" }
+    ],
+    "aaSorting": [[ 5, "desc" ]],
+    "fnPreDrawCallback": function( oSettings ) {
+      if (designs.allSelected()) {
+        designs.selectAll();
+      }
+    },
+    "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')}"
+      }
+    }
+  });
+
+  $(document).one('load.designs', function() {
+    designTable.fnDestroy();
+  });
+});
+designs.load();
+
+/**
+ * Using Mustache templating system: http://mustache.github.com/
+ * Templates and partials are loaded in jobsub.templates.js
+ * Context is matched up with templates and partials.
+ * Global context is extended to provide any uniqueness.
+ * Routie is used to provide hash routing: http://projects.jga.me/routie/.
+ */
+$(document).ready(function() {
+  //// Binding
+  ko.applyBindings(designs);
+
+  //// Routes
+  // Context matches up with jobsub.templates.js and various templates defined there.
+  // If there is an update to any of the templates,
+  // This global context may need to be updated.
+  var global_action_context = {
+    alert: "${_('You can parameterize the values, using')} <code>$myVar</code> ${_('or')} <code>${"${"}myVar}</code>. ${_('When the design is submitted, you will be prompted for the actual value of ')}<code>myVar</code>.",
+    shell_alert: "${_('Requires some SMTP server configuration to be present (in oozie-site.xml).')}",
+    ssh_alert: "${_('The ssh server requires passwordless login.')}",
+    save: {
+      name: "${_('Save')}",
+      func: "function(data, event) {$root.saveDesign.call($root, data, event);}"
+    },
+    cancel: {
+      name: "${_('Cancel')}",
+      func: "function(data, event) {$root.closeDesign.call($parent, {}); designs.load();}"
+    },
+    name: {
+      name: "${ _('Name') }",
+      popover: "${ _('Name of the design.') }"
+    },
+    description: {
+      name: "${ _('Description') }",
+      popover: "${ _('Description of the design.') }"
+    },
+    user: {
+      name: "${ _('User') }",
+      popover: "${ _('User to authenticate with.') }"
+    },
+    host: {
+      name: "${ _('Host') }",
+      popover: "${ _('Host to execute command on.') }"
+    },
+    command: {
+      name: "${ _('Command') }",
+      popover: "${ _('Command to execute.') }"
+    },
+    script_path: {
+      name: "${ _('Script name') }",
+      popover: "${ _('Path to the script to execute.') }"
+    },
+    jar_path: {
+      name: "${ _('Jar path') }",
+      popover: "${ _('Path to jar files on HDFS.') }"
+    },
+    main_class: {
+      name: "${ _('Main class') }",
+      popover: "${ _('Main class') }"
+    },
+    args: {
+      name: "${ _('Args') }",
+      popover: "${ _('Args') }"
+    },
+    java_opts: {
+      name: "${ _('Java opts') }",
+      popover: "${ _('Java opts') }"
+    },
+    mapper: {
+      name: "${ _('Mapper') }",
+      popover: "${ _('Mapper') }"
+    },
+    reducer: {
+      name: "${ _('Reducer') }",
+      popover: "${ _('Reducer') }"
+    },
+    to: {
+      name: "${ _('TO addresses') }",
+      popover: "${ _('TO addresses') }"
+    },
+    cc: {
+      name: "${ _('CC addresses (optional)') }",
+      popover: "${ _('CC addresses (optional)') }"
+    },
+    subject: {
+      name: "${ _('Subject') }",
+      popover: "${ _('Subject') }"
+    },
+    body: {
+      name: "${ _('Body') }",
+      popover: "${ _('Body') }"
+    },
+    job_properties: {
+      title: "${ _('Job Properties') }",
+      name: "${ _('Property name') }",
+      value: "${ _('Value') }",
+      delete: {
+        name: "${ _('Delete') }",
+        func: 'function(data, event) { $parent.removeProperty.call($parent, data, event) }'
+      },
+      add: {
+        name: "${ _('Add Property') }",
+        func: 'addProperty'
+      },
+      ko: {
+        items: "job_properties",
+        error_class: "job_properties_error_class",
+        condition: "job_properties_condition"
+      }
+    },
+    prepares: {
+      title: "${ _('Prepare') }",
+      name: "${ _('Type') }",
+      value: "${ _('Value') }",
+      delete: {
+        name: "${ _('Delete') }",
+        func: 'function(data, event) { $parent.removePrepare.call($parent, data, event) }'
+      },
+      add: {
+        delete: {
+          name: "${ _('Add delete') }",
+          func: 'addPrepareDelete'
+        },
+        mkdir: {
+          name: "${ _('Add mkdir') }",
+          func: 'addPrepareMkdir'
+        }
+      },
+      ko: {
+        items: "prepares",
+        error_class: "prepares_error_class",
+        condition: "prepares_condition"
+      }
+    },
+    params: {
+      title: "${ _('Params') }",
+      name: "${ _('Type') }",
+      value: "${ _('Value') }",
+      delete: {
+        name: "${ _('Delete') }",
+        func: 'function(data, event) { $parent.removeParam.call($parent, data, event) }'
+      },
+      add: [{
+        name: "${ _('Add param') }",
+        func: 'addParam'
+      }],
+      ko: {
+        items: "params",
+        error_class: "params_error_class",
+        condition: "params_condition"
+      }
+    },
+    files: {
+      title: "${ _('Files') }",
+      delete: {
+        name: "${ _('Delete') }",
+        func: 'function(data, event) { $parent.removeFile.call($parent, data, event) }'
+      },
+      add: {
+        name: "${ _('Add File') }",
+        func: 'addFile'
+      },
+      ko: {
+        items: "files",
+        error_class: "files_error_class",
+        condition: "files_condition"
+      }
+    },
+    archives: {
+      title: "${ _('Archives') }",
+      delete: {
+        name: "${ _('Delete') }",
+        func: 'function(data, event) { $parent.removeArchive.call($parent, data, event) }'
+      },
+      add: {
+        name: "${ _('Add Archive') }",
+        func: 'addArchive'
+      },
+      ko: {
+        items: "archives",
+        error_class: "archives_error_class",
+        condition: "archives_condition"
+      }
+    }
+  };
+
+  var contexts = {
+    mapreduce: {
+      title: "${ _('Job Design (mapreduce type)') }"
+    },
+    java: {
+      title: "${ _('Job Design (java type)') }"
+    },
+    streaming: {
+      title: "${ _('Job Design (streaming type)') }"
+    },
+    hive: {
+      title: "${ _('Job Design (hive type)') }"
+    },
+    pig: {
+      title: "${ _('Job Design (pig type)') }",
+      params: {
+        title: "${ _('Params') }",
+        name: "${ _('Type') }",
+        value: "${ _('Value') }",
+        delete: {
+          name: "${ _('Delete') }",
+          func: 'function(data, event) { $parent.removeParam.call($parent, data, event) }'
+        },
+        add: [{
+          name: "${ _('Add param') }",
+          func: 'addParam'
+        },{
+          name: "${ _('Add argument') }",
+          func: 'addArgument'
+        }],
+        ko: {
+          items: "params",
+          error_class: "params_error_class",
+          condition: "params_condition"
+        }
+      },
+    },
+    sqoop: {
+      title: "${ _('Job Design (sqoop type)') }",
+      script_path: {
+        name: "${ _('Command') }",
+        popover: "${ _('Command to execute.') }"
+      },
+      params: {
+        title: "${ _('Params') }",
+        name: "${ _('Type') }",
+        value: "${ _('Value') }",
+        delete: {
+          name: "${ _('Delete') }",
+          func: 'function(data, event) { $parent.removeParam.call($parent, data, event) }'
+        },
+        add: [{
+          name: "${ _('Add arg') }",
+          func: 'addArg'
+        }],
+        ko: {
+          items: "params",
+          error_class: "params_error_class",
+          condition: "params_condition"
+        }
+      }
+    },
+    fs: {
+      title: "${ _('Job Design (fs type)') }",
+      deletes: {
+        title: "${ _('Delete path') }",
+        delete: {
+          name: "${ _('Delete') }",
+          func: 'function(data, event) { $parent.removeDelete.call($parent, data, event) }'
+        },
+        add: {
+          name: "${ _('Add Path') }",
+          func: 'addDelete'
+        },
+        ko: {
+          items: "deletes",
+          error_class: "deletes_error_class",
+          condition: "deletes_condition"
+        }
+      },
+      mkdirs: {
+        title: "${ _('Create directory') }",
+        delete: {
+          name: "${ _('Delete') }",
+          func: 'function(data, event) { $parent.removeMkdir.call($parent, data, event) }'
+        },
+        add: {
+          name: "${ _('Add Path') }",
+          func: 'addMkdir'
+        },
+        ko: {
+          items: "mkdirs",
+          error_class: "mkdirs_error_class",
+          condition: "mkdirs_condition"
+        }
+      },
+      touchzs: {
+        title: "${ _('Create or touch file') }",
+        delete: {
+          name: "${ _('Delete') }",
+          func: 'function(data, event) { $parent.removeTouchz.call($parent, data, event) }'
+        },
+        add: {
+          name: "${ _('Add Path') }",
+          func: 'addTouchz'
+        },
+        ko: {
+          items: "touchzs",
+          error_class: "touchzs_error_class",
+          condition: "touchzs_condition"
+        }
+      },
+      chmods: {
+        title: "${ _('Change permissions') }",
+        delete: {
+          name: "${ _('Delete') }",
+          func: 'function(data, event) { $parent.removeChmod.call($parent, data, event) }'
+        },
+        add: {
+          name: "${ _('Add chmod') }",
+          func: 'addChmod'
+        },
+        ko: {
+          items: "chmods",
+          error_class: "chmods_error_class",
+          condition: "chmods_condition"
+        }
+      },
+      moves: {
+        title: "${ _('Move file') }",
+        delete: {
+          name: "${ _('Delete') }",
+          func: 'function(data, event) { $parent.removeMove.call($parent, data, event) }'
+        },
+        add: {
+          name: "${ _('Add move') }",
+          func: 'addMove'
+        },
+        ko: {
+          items: "moves",
+          error_class: "moves_error_class",
+          condition: "moves_condition"
+        }
+      }
+    },
+    ssh: {
+      title: "${ _('Job Design (ssh type)') }",
+      params: {
+        title: "${ _('Params') }",
+        name: "${ _('Type') }",
+        value: "${ _('Value') }",
+        delete: {
+          name: "${ _('Delete') }",
+          func: 'function(data, event) { $parent.removeParam.call($parent, data, event) }'
+        },
+        add: [{
+          name: "${ _('Add arg') }",
+          func: 'addArg'
+        }],
+        ko: {
+          items: "params",
+          error_class: "params_error_class",
+          condition: "params_condition"
+        }
+      },
+    },
+    shell: {
+      title: "${ _('Job Design (shell type)') }",
+      params: {
+        title: "${ _('Params') }",
+        name: "${ _('Type') }",
+        value: "${ _('Value') }",
+        delete: {
+          name: "${ _('Delete') }",
+          func: 'function(data, event) { $parent.removeParam.call($parent, data, event) }'
+        },
+        add: [{
+          name: "${ _('Add argument') }",
+          func: 'addArgument'
+        },{
+          name: "${ _('Add Env-Var') }",
+          func: 'addEnvVar'
+        }],
+        ko: {
+          items: "params",
+          error_class: "params_error_class",
+          condition: "params_condition"
+        }
+      },
+    },
+    email: {
+      title: "${ _('Job Design (email type)') }"
+    },
+    distcp: {
+      title: "${ _('Job Design (distcp type)') }",
+      params: {
+        title: "${ _('Params') }",
+        name: "${ _('Type') }",
+        value: "${ _('Value') }",
+        delete: {
+          name: "${ _('Delete') }",
+          func: 'function(data, event) { $parent.removeParam.call($parent, data, event) }'
+        },
+        add: [{
+          name: "${ _('Add Argument') }",
+          func: 'addArgument'
+        }],
+        ko: {
+          items: "params",
+          error_class: "params_error_class",
+          condition: "params_condition"
+        }
+      },
+    }
+  }
+
+  routie({
+    'new-design/:node_type': function(node_type) {
+      /**
+       * Update context with correct title.
+       * Create empty design to fill.
+       * Create template by calling `getActionTemplate`.
+       */
+      // Show section only after we've finished the new design process.
+      $(document).one('new.design', function() {
+        showSection('design');
+      });
+
+      designs.closeDesign();
+      var context = $.extend(true, {}, global_action_context, contexts[node_type]);
+      templates.getActionTemplate(node_type, context);
+      designs.newDesign(node_type);
+    },
+    'edit-design/:index': function(index) {
+      /**
+       * Update context with correct title.
+       * Design is selected through 'list-designs'.
+       */
+      designs.closeDesign();
+
+      var designObject = designs.designs()[index];
+      if (!designObject) {
+        routie('list-designs');
+        return;
+      }
+
+      if (!designObject.design().editable()) {
+        routie('list-designs');
+        $.jHueNotify.error("${ _('Design is not editable. It is owned by user ') }" + designObject.design().owner() + '.');
+        return;
+      }
+
+      // Show section only after we've finished the edit design process.
+      $(document).one('edit.design', function() {
+        showSection('design');
+      });
+
+      var node_type = designObject.design().node_type();
+      var context = $.extend(true, {}, global_action_context, contexts[node_type]);
+      templates.getActionTemplate(node_type, context);
+      designs.deselectAll();
+      designs.select(index);
+      designs.editDesign();
+    },
+    'list-designs': function() {
+      showSection('list-designs');
+    }
+  });
+  routie('list-designs');
+
+  //// Row selector, buttons, and various features.
+  $(".btn[rel='tooltip']").tooltip({placement:'bottom'});
+  $("a[data-row-selector='true']").jHueRowSelector();
+  $('#submit-design').click(function() {
+    var url = '/oozie/submit_workflow/' + designs.selectedDesign().id();
+    $.get(url, function (response) {
+        $('#submitWf').html(response);
+        $('#submitWf').modal('show');
+      }
+    );
+  });
+  $('#edit-design').click(function() {
+    window.location = '#edit-design/' + designs.selectedIndex();
+  });
+  $('#delete-designs').click(function() {
+    $('#deleteWf').modal('show');
+  });
+});
+</script>
+
+${ commonfooter(messages) | n,unicode }

+ 0 - 335
apps/jobsub/src/jobsub/templates/edit_design.mako

@@ -1,335 +0,0 @@
-## 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.
-
-<%!
-import urllib
-from desktop.views import commonheader, commonfooter
-from desktop.lib.django_util import extract_field_data
-from django.utils.translation import ugettext as _
-%>
-
-<%namespace name="layout" file="layout.mako" />
-
-${ commonheader(_('Job Designer'), "jobsub", user, "100px") | n,unicode }
-${layout.menubar(section='designs')}
-
-
-
-<link rel="stylesheet" href="/static/ext/css/jquery-ui-autocomplete-1.8.18.css" type="text/css" media="screen" title="no title" charset="utf-8" />
-<script src="/static/ext/js/knockout-2.1.0.js" type="text/javascript" charset="utf-8"></script>
-<script src="/static/ext/js/jquery/plugins/jquery-ui-autocomplete-1.8.18.min.js" type="text/javascript" charset="utf-8"></script>
-
-
-
-
-<%def name="render_field(field, show_label=True, extra_attrs={})">
-  % if not field.is_hidden:
-    <% group_class = field.errors and "error" or "" %>
-    <div class="control-group ${group_class}"
-      rel="popover" data-original-title="${ field.label }" data-content="${ field.help_text }">
-      % if show_label:
-        <label class="control-label">${ field.label }</label>
-      % endif
-      <div class="controls">
-        <% field.field.widget.attrs.update(extra_attrs) %>
-        ${ field | n,unicode }
-        % if field.errors:
-          <span class="help-inline">${ unicode(field.errors) | n,unicode }</span>
-        % endif
-      </div>
-    </div>
-  %endif
-</%def>
-
-<div class="container-fluid">
-  <h1>${_('Job Design (%(type)s type)') % dict(type=action_type)}</h1>
-
-  <form class="form-horizontal" id="workflowForm" action="${urllib.quote(action)}" method="POST">
-    <fieldset>
-
-        % for field in form.wf:
-          ${render_field(field)}
-        % endfor
-
-        <hr/>
-        <div class="control-group">
-          <p class="alert alert-info">
-              ${_('You can parameterize the values, using')} <code>$myVar</code> ${_('or')}
-              <code>${"${"}myVar}</code> .
-              ${_('When the design is submitted, you will be prompted for the actual value of ')}<code>myVar</code> .
-          </p>
-        </div>
-        % for field in form.action:
-          ${render_field(field)}
-        % endfor
-
-        <div class="control-group">
-            <label class="control-label">${_('Job Properties')}</label>
-            <div class="controls">
-                ## Data bind for job properties
-                <table class="table-condensed designTable" data-bind="visible: properties().length > 0">
-                  <thead>
-                    <tr>
-                      <th>${_('Property name')}</th>
-                      <th>${_('Value')}</th>
-                      <th />
-                    </tr>
-                  </thead>
-                  <tbody data-bind="foreach: properties">
-                    <tr>
-                      <td><input type="text" class="span3 required propKey" data-bind="value: name, uniqueName: false" /></td>
-                      <td><input type="text" class="span4 required pathChooserKo" data-bind="fileChooser: $data, value: value, uniqueName: false" /></td>
-                      <td><a class="btn btn-small" href="#" data-bind="click: $root.removeProp">${_('Delete')}</a></td>
-                    </tr>
-                  </tbody>
-                </table>
-                % if len(form.action["job_properties"].errors):
-                  <div class="row">
-                    <div class="alert alert-error">
-                      ${unicode(form.action["job_properties"].errors) | n}
-                    </div>
-                  </div>
-                % endif
-
-                <button class="btn" data-bind="click: addProp">${_('Add Property')}</button>
-            </div>
-        </div>
-
-        <div class="control-group">
-            <label class="control-label">${_('Files')}</label>
-            <div class="controls">
-                ## Data bind for files (distributed cache)
-                <table class="table-condensed designTable" data-bind="visible: files().length > 0">
-                  <tbody data-bind="foreach: files">
-                    <tr>
-                      <td><input type="text" class="input span5 required pathChooserKo"
-                                data-bind="fileChooser: $data, value: name, uniqueName: false" /></td>
-                      <td><a class="btn" href="#" data-bind="click: $root.removeFile">${_('Delete')}</a></td>
-                    </tr>
-                  </tbody>
-                </table>
-                % if len(form.action["files"].errors):
-                    <div class="alert alert-error">
-                      ${unicode(form.action["files"].errors) | n}
-                    </div>
-                % endif
-
-                <button class="btn" data-bind="click: addFile">${_('Add File')}</button>
-            </div>
-        </div>
-
-        <div class="control-group">
-            <label class="control-label">${_('Archives')}</label>
-            <div class="controls">
-                ## Data bind for archives (distributed cache)
-                <table class="table-condensed designTable" data-bind="visible: archives().length > 0">
-                  <tbody data-bind="foreach: archives">
-                    <tr>
-                      <td><input type="text" class="input span5 required pathChooserKo"
-                                data-bind="fileChooser: $data, value: name, uniqueName: false" /></td>
-                      <td><a class="btn" href="#" data-bind="click: $root.removeArchive">${_('Delete')}</a></td>
-                    </tr>
-                  </tbody>
-                </table>
-                % if len(form.action["archives"].errors):
-                    <div class="alert alert-error">
-                      ${unicode(form.action["archives"].errors) | n}
-                    </div>
-                % endif
-
-                <button class="btn" data-bind="click: addArchive">${_('Add Archive')}</button>
-            </div>
-        </div>
-    </fieldset>
-
-    ## Submit
-    <div class="form-actions">
-      <button data-bind="click: submit" class="btn btn-primary">${_('Save')}</button>
-      <a href="/jobsub" class="btn">${_('Cancel')}</a>
-    </div>
-  </form>
-
-</div>
-
-
-<div id="chooseFile" class="modal hide fade">
-    <div class="modal-header">
-        <a href="#" class="close" data-dismiss="modal">&times;</a>
-        <h3>${_('Choose a file')}</h3>
-    </div>
-    <div class="modal-body">
-        <div id="fileChooserModal">
-        </div>
-    </div>
-    <div class="modal-footer">
-    </div>
-</div>
-
-
-<style>
-    #fileChooserModal {
-        padding:14px;
-        height:270px;
-    }
-    #fileChooserModal > ul.unstyled {
-        height:180px;
-        overflow-y:auto;
-    }
-    .designTable {
-        margin-left:0;
-    }
-    .designTable th, .designTable td {
-        padding-left: 0;
-    }
-    .designTable th {
-        text-align:left;
-    }
-</style>
-
-
-<script type="text/javascript" charset="utf-8">
-    $(document).ready(function(){
-        var propertiesHint = ${ properties_hint | n,unicode };
-
-        // The files and archives are dictionaries in the model, because we
-        // can add and remove it the same way we add/remove properties.
-        // But the server expects them to be arrays. So we transform the
-        // two representations back and forth.
-        var arrayToDictArray = function(arr) {
-            var res = [ ];
-            for (var i in arr) {
-                res.push( { name: arr[i], dummy: "" } );
-            }
-            return res;
-        };
-
-        var dictArrayToArray = function(dictArray) {
-            var res = [ ];
-            for (var i in dictArray) {
-                res.push(dictArray[i]["name"]);
-            }
-            return res;
-        };
-
-        // Handles adding autocomplete to job properties.
-        // We need to propagate the selected value to knockoutjs.
-        var addAutoComplete = function(i, elem) {
-            $(elem).autocomplete({
-                source: propertiesHint,
-                select: function(event, ui) {
-                    var context = ko.contextFor(this);
-                    context.$data.name = ui.item.value;
-
-                }
-            });
-        };
-
-        var ViewModel = function(properties, files, archives) {
-            var self = this;
-
-            self.properties = ko.observableArray(properties);
-            self.files = ko.observableArray(files);
-            self.archives = ko.observableArray(archives);
-            self.myVar = ko.observable();
-
-            self.addProp = function() {
-                self.properties.push({ name: "", value: "" });
-                $(".propKey:last").each(addAutoComplete);
-            };
-
-            self.removeProp = function(val) {
-                self.properties.remove(val);
-            };
-
-            self.addFile = function() {
-                self.files.push({ name: "", dummy: "" });
-            };
-
-            self.removeFile = function(val) {
-                self.files.remove(val);
-            };
-
-            self.addArchive = function() {
-                self.archives.push({ name: "", dummy: "" });
-            };
-
-            self.removeArchive = function(val) {
-                self.archives.remove(val);
-            };
-
-            self.submit = function(form) {
-                var form = $("#workflowForm");
-                var files_arr = dictArrayToArray(ko.toJS(self.files));
-                var archives_arr = dictArrayToArray(ko.toJS(self.archives));
-
-                $("<input>").attr("type", "hidden")
-                    .attr("name", "action-job_properties")
-                    .attr("value", ko.utils.stringifyJson(self.properties))
-                    .appendTo(form);
-                $("<input>").attr("type", "hidden")
-                    .attr("name", "action-files")
-                    .attr("value", JSON.stringify(files_arr))
-                    .appendTo(form);
-                $("<input>").attr("type", "hidden")
-                    .attr("name", "action-archives")
-                    .attr("value", JSON.stringify(archives_arr))
-                    .appendTo(form);
-                form.submit();
-            };
-        };
-
-        var viewModel = new ViewModel(
-                ${ properties | n,unicode },
-                arrayToDictArray(${ files | n,unicode }),
-                arrayToDictArray(${ archives | n,unicode }));
-
-        ko.bindingHandlers.fileChooser = {
-            init: function(element, valueAccessor, allBindings, model) {
-                var self = $(element);
-                self.after(getFileBrowseButton(self));
-            }
-        };
-
-        ko.applyBindings(viewModel);
-
-        $(".pathChooser").each(function(){
-            var self = $(this);
-            self.after(getFileBrowseButton(self));
-        });
-
-        function getFileBrowseButton(inputElement) {
-            return $("<button>").addClass("btn").addClass("fileChooserBtn").text("..").click(function(e){
-                e.preventDefault();
-                $("#fileChooserModal").jHueFileChooser({
-                    initialPath: inputElement.val(),
-                    onFileChoose: function(filePath) {
-                        inputElement.val(filePath);
-                        inputElement.change();
-                        $("#chooseFile").modal("hide");
-                    },
-                    createFolder: false
-                });
-                $("#chooseFile").modal("show");
-            })
-        }
-
-
-        $(".propKey").each(addAutoComplete);
-    });
-</script>
-
-
-${ commonfooter(messages) | n,unicode }

+ 0 - 40
apps/jobsub/src/jobsub/templates/layout.mako

@@ -1,40 +0,0 @@
-## 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.
-##
-##
-## no spaces in this method please; we're declaring a CSS class, and ART uses this value for stuff, and it splits on spaces, and
-## multiple spaces and line breaks cause issues
-<%!
-from django.utils.translation import ugettext as _
-
-def is_selected(section, matcher):
-  if section == matcher:
-    return "active"
-  else:
-    return ""
-%>
-
-<%def name="menubar(section='')">
-	<div class="subnav subnav-fixed">
-		<div class="container-fluid">
-			<ul class="nav nav-pills">
-				<li class="${is_selected(section, 'designs')}"><a href="${url('jobsub.views.list_designs')}">${_('Designs')}</a></li>
-				<li class="${is_selected(section, 'history')}"><a href="${url('jobsub.views.list_history')}">${_('History')}</a></li>
-			</ul>
-		</div>
-	</div>
-</%def>
-

+ 0 - 200
apps/jobsub/src/jobsub/templates/list_designs.mako

@@ -1,200 +0,0 @@
-## 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.
-
-<%!
-import cgi
-import urllib
-from desktop.views import commonheader, commonfooter
-from django.utils.translation import ugettext as _
-%>
-
-<%namespace name="commonlayout" file="layout.mako" />
-<%namespace name="actionbar" file="actionbar.mako" />
-
-${ commonheader(_('Job Designer'), "jobsub", user, "100px") | n,unicode }
-${commonlayout.menubar(section='designs')}
-
-<script src="/static/ext/js/datatables-paging-0.1.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/moment.min.js" type="text/javascript" charset="utf-8"></script>
-<script src="static/js/jobsub.ko.js" type="text/javascript" charset="utf-8"></script>
-
-
-<%def name="layout()">
-  <div class="container-fluid">
-    <h1>${_('Job Designs')}</h1>
-
-  <%actionbar:render>
-    <%def name="actions()">
-        <button class="btn" title="${_('Submit')}" data-bind="click: submitDesign, enable: selectedDesigns().length == 1 && selectedDesigns()[0].canSubmit"><i class="icon-play"></i> ${_('Submit')}</button>
-        <button class="btn" title="${_('Edit')}" data-bind="click: editDesign, enable: selectedDesigns().length == 1 && selectedDesigns()[0].canSubmit"><i class="icon-pencil"></i> ${_('Edit')}</button>
-        <button class="btn" title="${_('Delete')}" data-bind="click: deleteDesign, enable: selectedDesigns().length == 1 && selectedDesigns()[0].canDelete"><i class="icon-trash"></i> ${_('Delete')}</button>
-        <button class="btn" title="${_('Clone')}" data-bind="click: cloneDesign, enable: selectedDesigns().length == 1"><i class="icon-share"></i> ${_('Clone')}</button>
-    </%def>
-    <%def name="creation()">
-        <span class="btn-group">
-                <a href="${ url('jobsub.views.new_design', action_type='mapreduce') }" class="btn" title="${_('Create MapReduce Design')}" rel="tooltip"><i class="icon-plus-sign"></i> ${_('MapReduce')}</a>
-                <a href="${ url('jobsub.views.new_design', action_type='streaming') }" class="btn" title="${_('Create Streaming Design')}" rel="tooltip"><i class="icon-plus-sign"></i> Streaming</a>
-                <a href="${ url('jobsub.views.new_design', action_type='java') }" class="btn"title="${_('Create Java Design')}" rel="tooltip"><i class="icon-plus-sign"></i> ${_('Java')}</a>
-            </span>
-      %if show_install_examples:
-          &nbsp; <a id="installSamplesLink" href="javascript:void(0)" data-confirmation-url="${url('jobsub.views.setup')}" class="btn"><i class="icon-download-alt"></i> ${_('Install Samples')}</a>
-      %endif
-    </%def>
-  </%actionbar:render>
-
-    <table id="designTable" class="table table-condensed datatables">
-      <thead>
-      <tr>
-        <th width="1%"><div id="selectAll" data-bind="click: selectAll, css: {hueCheckbox: true, 'icon-ok': allSelected}"></div></th>
-        <th>${_('Owner')}</th>
-        <th>${_('Name')}</th>
-        <th>${_('Type')}</th>
-        <th>${_('Description')}</th>
-        <th>${_('Last Modified')}</th>
-      </tr>
-      </thead>
-      <tbody id="designs" data-bind="template: {name: 'designTemplate', foreach: designs}">
-
-      </tbody>
-    </table>
-
-  </div>
-
-  <script id="designTemplate" type="text/html">
-    <tr style="cursor: pointer">
-      <td class="center" data-bind="click: handleSelect" style="cursor: default">
-        <div data-bind="visible: name != '..', css: {hueCheckbox: name != '..', 'icon-ok': selected}"></div>
-      </td>
-      <td data-bind="click: $root.editDesign, text: owner"></td>
-      <td data-bind="click: $root.editDesign, text: name"></td>
-      <td data-bind="click: $root.editDesign, text: type"></td>
-      <td data-bind="click: $root.editDesign, text: description"></td>
-      <td data-bind="click: $root.editDesign, text: lastModified, attr: { 'data-sort-value': lastModifiedMillis }" style="white-space: nowrap;"></td>
-    </tr>
-  </script>
-</%def>
-
-${layout()}
-
-<div id="submitWf" class="modal hide fade">
-    <form id="submitWfForm" action="" method="POST" style="margin:0">
-        <div class="modal-header">
-            <a href="#" class="close" data-dismiss="modal">&times;</a>
-            <h3 id="submitWfMessage">${_('Submit this design?')}</h3>
-        </div>
-        <div class="modal-body">
-            <fieldset>
-                <div id="param-container">
-                </div>
-            </fieldset>
-        </div>
-        <div class="modal-footer">
-            <a href="#" class="btn" data-dismiss="modal">${_('Cancel')}</a>
-            <input id="submitBtn" type="submit" class="btn btn-primary" value="${_('Submit')}"/>
-        </div>
-    </form>
-</div>
-
-<div id="deleteWf" class="modal hide fade">
-    <form id="deleteWfForm" action="" method="POST" style="margin:0">
-        <div class="modal-header">
-            <a href="#" class="close" data-dismiss="modal">&times;</a>
-            <h3 id="deleteWfMessage">${_('Delete this design?')}</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>
-
-<div id="installSamples" class="modal hide fade">
-    <form id="installSamplesForm" action="${url('jobsub.views.setup')}" method="POST" style="margin:0">
-        <div class="modal-header">
-            <a href="#" class="close" data-dismiss="modal">&times;</a>
-            <h3>${_('Install sample job designs?')}</h3>
-        </div>
-        <div class="modal-body">
-            ${_('It will take a few seconds to install.')}
-        </div>
-        <div class="modal-footer">
-            <a href="#" class="btn" data-dismiss="modal">${_('No')}</a>
-            <input type="submit" class="btn btn-primary" value="${_('Yes')}"/>
-        </div>
-    </form>
-</div>
-
-
-<script type="text/javascript" charset="utf-8">
-
-    var deleteMessage = "${_('Are you sure you want to delete %(name)s?') % dict(name='##PLACEHOLDER##')}";
-    var submitMessage = "${_('Submit %(name)s to the cluster') % dict(name='##PLACEHOLDER##')}";
-
-    $(document).ready(function() {
-        var designTable, viewModel;
-
-        $("#filterInput").keyup(function() {
-            if (designTable != null){
-                designTable.fnFilter($(this).val());
-            }
-        });
-
-        $("#installSamplesLink").click(function(){
-            $("#installSamples").modal("show");
-        });
-
-        viewModel = new JobSubModel(${ designs | n });
-        ko.applyBindings(viewModel);
-        designTable = $('#designTable').dataTable( {
-            "sPaginationType": "bootstrap",
-            "bLengthChange": false,
-            "sDom": "<'row'r>t<'row'<'span8'i><''p>>",
-            "aoColumns": [
-                { "bSortable": false },
-                null,
-                null,
-                null,
-                null,
-                { "sSortDataType": "dom-sort-value", "sType": "numeric" }
-            ],
-            "aaSorting": [[ 5, "desc" ]],
-            "fnPreDrawCallback": function( oSettings ) {
-                if (viewModel.allSelected()){
-                    viewModel.selectAll();
-                }
-            },
-            "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')}"
-                }
-            }
-        });
-
-        $(".btn[rel='tooltip']").tooltip({placement:'bottom'});
-        $("a[data-row-selector='true']").jHueRowSelector();
-    });
-</script>
-
-${ commonfooter(messages) | n,unicode }

+ 0 - 101
apps/jobsub/src/jobsub/templates/list_history.mako

@@ -1,101 +0,0 @@
-## 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.
-
-<%!
-import urllib
-import time as py_time
-from django.template.defaultfilters import date, time
-from desktop.views import commonheader, commonfooter
-from django.utils.translation import ugettext as _
-%>
-<%namespace name="layout" file="layout.mako" />
-<%namespace name="actionbar" file="actionbar.mako" />
-
-${ commonheader(_('Job Designer'), "jobsub", user, "100px") | n,unicode }
-${layout.menubar(section='history')}
-
-<script src="/static/ext/js/datatables-paging-0.1.js" type="text/javascript" charset="utf-8"></script>
-
-<div class="container-fluid">
-    <h1>${_('Job Submission History')}</h1>
-
-    <%actionbar:render />
-
-    <table class="table table-condensed datatables" id="jobTable">
-        <thead>
-        <tr>
-            <th>${_('Oozie Job ID')}</th>
-            <th>${_('Owner')}</th>
-            <th>${_('Name')}</th>
-            <th>${_('Type')}</th>
-            <th>${_('Description')}</th>
-            <th>${_('Submission Date')}</th>
-        </tr>
-        </thead>
-        <tbody>
-                %for record in history:
-                <% design = record.design %>
-                <tr>
-                    <td><a href="${url('jobsub.views.oozie_job', jobid=record.job_id)}">${record.job_id}</a></td>
-                    <td>${record.owner.username}</td>
-                    <td>${design.name}</td>
-                    <td>${design.root_action.action_type}</td>
-                    <td>${design.description}</td>
-                    <td data-sort-value="${py_time.mktime(record.submission_date.timetuple())}">${date(record.submission_date)} ${time(record.submission_date).replace("p.m.","PM").replace("a.m.","AM")}</td>
-                </tr>
-                %endfor
-        </tbody>
-    </table>
-</div>
-
-<script type="text/javascript" charset="utf-8">
-    $(document).ready(function() {
-        var oTable = $('#jobTable').dataTable( {
-            'sPaginationType': 'bootstrap',
-            "bLengthChange": false,
-            "sDom": "<'row'r>t<'row'<'span8'i><''p>>",
-            "aoColumns": [
-                null,
-                null,
-                null,
-                null,
-                null,
-                { "sSortDataType": "dom-sort-value", "sType": "numeric" }
-            ],
-            "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')}"
-                }
-            }
-        });
-
-        $("#filterInput").keyup(function() {
-            oTable.fnFilter($(this).val());
-        });
-
-    });
-</script>
-
-${ commonfooter(messages) | n,unicode }

+ 0 - 19
apps/jobsub/src/jobsub/templates/status_bar.mako

@@ -1,19 +0,0 @@
-## 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.
-<%! import jobsub %>
-% if pending_count:
-<a href="${url(jobsub.views.watch)}">${pending_count} pending</a>
-% endif

+ 0 - 52
apps/jobsub/src/jobsub/templates/workflow-common.xml.mako

@@ -1,52 +0,0 @@
-## 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.
-##
-##
-## no spaces in this method please; we're declaring a CSS class, and ART uses this value for stuff, and it splits on spaces, and 
-## multiple spaces and line breaks cause issues
-<%!
-import posixpath
-%> 
-
-<%def name="filelink(path)">${path + '#' + posixpath.basename(path)}</%def>
-
-## Please keep the indentation. The generated XML looks better that way.
-<%def name="configuration(properties)">
-        %if properties:
-            <configuration>
-                %for p in properties:
-                <property>
-                    <name>${p['name']}</name>
-                    <value>${p['value']}</value>
-                </property>
-                %endfor
-            </configuration>
-        %endif
-</%def>
-
-## Please keep the indentation. The generated XML looks better that way.
-<%def name="distributed_cache(files, archives)">
-    %for f in files:
-        %if len(f) != 0:
-            <file>${f + '#' + posixpath.basename(f)}</file>
-        %endif
-    %endfor
-    %for a in archives:
-        %if len(a) != 0:
-            <archive>${a}</archive>
-        %endif
-    %endfor
-</%def>

+ 0 - 58
apps/jobsub/src/jobsub/templates/workflow-java.xml.mako

@@ -1,58 +0,0 @@
-## 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.
-<%namespace name="common" file="workflow-common.xml.mako" />
-<%!
-try:
-    import json
-except ImportError:
-    import simplejson as json
-%>
-<%
-    java = design.get_root_action()
-    properties = json.loads(java.job_properties)
-    files = json.loads(java.files)
-    archives = json.loads(java.archives)
-%>
-<workflow-app xmlns="uri:oozie:workflow:0.2" name="${design.name}">
-    <start to="root-node"/>
-    <action name="root-node">
-        <java>
-            ## Do not hardcode the jobtracker/resourcemanager address.
-            ## We want to be flexible where to submit it to.
-            <job-tracker>${'${'}jobTracker}</job-tracker>
-            <name-node>${nameNode}</name-node>
-
-            ${common.configuration(properties)}
-
-            <main-class>${java.main_class}</main-class>
-            %for arg in java.args.split():
-            <arg>${arg}</arg>
-            %endfor
-
-            %if len(java.java_opts):
-            <java-opts>${java.java_opts}</java-opts>
-            %endif
-
-            ${common.distributed_cache(files, archives)}
-        </java>
-        <ok to="end"/>
-        <error to="fail"/>
-    </action>
-    <kill name="fail">
-        <message>Java failed, error message[${'${'}wf:errorMessage(wf:lastErrorNode())}]</message>
-    </kill>
-    <end name="end"/>
-</workflow-app>

+ 0 - 49
apps/jobsub/src/jobsub/templates/workflow-mapreduce.xml.mako

@@ -1,49 +0,0 @@
-## 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.
-<%namespace name="common" file="workflow-common.xml.mako" />
-<%!
-try:
-    import json
-except ImportError:
-    import simplejson as json
-
-%>
-<%
-    mapreduce = design.get_root_action()
-    properties = json.loads(mapreduce.job_properties)
-    files = json.loads(mapreduce.files)
-    archives = json.loads(mapreduce.archives)
-%>
-<workflow-app xmlns="uri:oozie:workflow:0.2" name="${design.name}">
-    <start to="root-node"/>
-    <action name="root-node">
-        <map-reduce>
-            ## Do not hardcode the jobtracker/resourcemanager address.
-            ## We want to be flexible where to submit it to.
-            <job-tracker>${'${'}jobTracker}</job-tracker>
-            <name-node>${nameNode}</name-node>
-
-            ${common.configuration(properties)}
-            ${common.distributed_cache(files, archives)}
-        </map-reduce>
-        <ok to="end"/>
-        <error to="fail"/>
-    </action>
-    <kill name="fail">
-        <message>MapReduce failed, error message[${'${'}wf:errorMessage(wf:lastErrorNode())}]</message>
-    </kill>
-    <end name="end"/>
-</workflow-app>

+ 0 - 53
apps/jobsub/src/jobsub/templates/workflow-streaming.xml.mako

@@ -1,53 +0,0 @@
-## 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.
-<%namespace name="common" file="workflow-common.xml.mako" />
-<%!
-try:
-    import json
-except ImportError:
-    import simplejson as json
-
-%>
-<%
-    streaming = design.get_root_action()
-    properties = json.loads(streaming.job_properties)
-    files = json.loads(streaming.files)
-    archives = json.loads(streaming.archives)
-%>
-<workflow-app xmlns="uri:oozie:workflow:0.2" name="${design.name}">
-    <start to="root-node"/>
-    <action name="root-node">
-        <map-reduce>
-            ## Do not hardcode the jobtracker/resourcemanager address.
-            ## We want to be flexible where to submit it to.
-            <job-tracker>${'${'}jobTracker}</job-tracker>
-            <name-node>${nameNode}</name-node>
-            <streaming>
-                <mapper>${streaming.mapper}</mapper>
-                <reducer>${streaming.reducer}</reducer>
-            </streaming>
-
-            ${common.configuration(properties)}
-            ${common.distributed_cache(files, archives)}
-        </map-reduce>
-        <ok to="end"/>
-        <error to="fail"/>
-    </action>
-    <kill name="fail">
-        <message>Streaming failed, error message[${'${'}wf:errorMessage(wf:lastErrorNode())}]</message>
-    </kill>
-    <end name="end"/>
-</workflow-app>

+ 0 - 241
apps/jobsub/src/jobsub/templates/workflow.mako

@@ -1,241 +0,0 @@
-## 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.
-<%!
-  import time
-
-  from desktop.views import commonheader, commonfooter
-  from hadoop.fs.hadoopfs import Hdfs
-  from django.utils.translation import ugettext as _
-%>
-<%namespace name="layout" file="layout.mako" />
-
-${ commonheader(_("Job Designer"), "jobsub", user, "100px") | n,unicode }
-${layout.menubar(section='history')}
-
-<%def name="format_time(st_time)">
-  % if st_time is None:
-    -
-  % else:
-    ${time.strftime("%a, %d %b %Y %H:%M:%S", st_time)}
-  % endif
-</%def>
-
-<%def name="hdfs_link(url)">
-  <% path = Hdfs.urlsplit(url)[2] %>
-  % if path:
-    <a href="/filebrowser/view${path}" target="FileBrowser">${url}</a>
-  % else:
-    ${url}
-  % endif
-</%def>
-
-<%def name="configModal(elementId, title, configs)">
-  <div id="${elementId}" class="modal hide fade">
-      <div class="modal-header">
-          <a href="#" class="close" data-dismiss="modal">&times;</a>
-          <h3>${title}</h3>
-      </div>
-      <div class="modal-body">
-          <table class="table table-condensed table-striped">
-            <thead>
-              <tr>
-                <th>${_('Name')}</th>
-                <th>${_('Value')}</th>
-              </tr>
-            </thead>
-            <tbody>
-              % for name, value in sorted(configs.items()):
-                <tr>
-                  <td>${name}</td>
-                  <td>
-                    ## Try to detect paths
-                    %if name.endswith('dir') or name.endswith('path'):
-                      ${hdfs_link(value)}
-                    %else:
-                      ${value}
-                    %endif
-                  </td>
-                </tr>
-              % endfor
-            </tbody>
-          </table>
-      </div>
-  </div>
-</%def>
-
-<div class="container-fluid">
-    %if design_link is not None:
-    <h1><a title="${_('Edit design')}" href="${design_link}">${workflow.appName}</a> (${workflow.id})</h1>
-    %else:
-    <h1>${workflow.appName} (${workflow.id})</h1>
-    %endif
-
-    ## Tab headers
-    <ul class="nav nav-tabs">
-        <li class="active"><a href="#actions" data-toggle="tab">${_('Actions')}</a></li>
-        <li><a href="#details" data-toggle="tab">${_('Details')}</a></li>
-        <li><a href="#definition" data-toggle="tab">${_('Definition')}</a></li>
-        <li><a href="#log" data-toggle="tab">${_('Log')}</a></li>
-    </ul>
-
-    <div id="workflow-tab-content" class="tab-content">
-      ## Tab: Actions
-      <div class="tab-pane active" id="actions">
-        <table data-filters="HtmlTable" class="table table-striped table-condensed selectable sortable" cellpadding="0" cellspacing="0">
-          <thead>
-            <tr>
-              <th>${_('Name')}</th>
-              <th>${_('Type')}</th>
-              <th>${_('Status')}</th>
-              <th>${_('External Id')}</th>
-
-              <th>${_('Start Time')}</th>
-              <th>${_('End Time')}</th>
-
-              <th>${_('Retries')}</th>
-              <th>${_('Error Message')}</th>
-              <th>${_('Transition')}</th>
-
-              <th>${_('Data')}</th>
-            </tr>
-          </thead>
-          <tbody>
-            % for i, action in enumerate(workflow.actions):
-              <tr>
-                <td>
-                  ## Include a modal for action configuration
-                  ${action.name}
-                  <% modal_id = "actionConfigModal" + str(i) %>
-                  <a href="#${modal_id}" data-toggle="modal"><img src="/static/art/led-icons/cog.png"
-                      alt="Show Configuration"></a>
-                  ${configModal(modal_id, "Action Configuration", action.conf_dict)}
-                </td>
-                <td>${action.type}</td>
-                <td>${action.status}</td>
-
-                <td>
-                % if action.externalId:
-                  <a href="${ url('jobbrowser.views.single_job', job=action.externalId) }">${ "_".join(action.externalId.split("_")[-2:]) }</a>
-                % endif
-                </td>
-
-                <td>${format_time(action.startTime)}</td>
-                <td>${format_time(action.endTime)}</td>
-
-                <td>${action.retries}</td>
-                <td>${action.errorMessage}</td>
-                <td>${action.transition}</td>
-
-                <td>${action.data}</td>
-              </tr>
-            % endfor
-          <tbody>
-        </table>
-      </div>
-
-        ## Tab: Job details
-        <div class="tab-pane" id="details">
-          <table data-filters="HtmlTable" class="table table-striped table-condensed selectable sortable" cellpadding="0" cellspacing="0">
-            <tbody>
-              <tr>
-                ## App name + configuration
-                <td>${_('Application Name')}</td>
-                <td>
-                  ${workflow.appName}
-                  <a href="#appConfigModal" data-toggle="modal"><img src="/static/art/led-icons/cog.png"
-                      alt="Show Configuration"/></a>
-                </td>
-              </tr>
-              <tr>
-                <td>${_('User')}</td>
-                <td>${workflow.user}</td>
-              </tr>
-              <tr>
-                <td>${_('Group')}</td>
-                <td>${workflow.group}</td>
-              </tr>
-              <tr>
-                <td>${_('Status')}</td>
-                <td>${workflow.status}</td>
-              </tr>
-              <tr>
-                <td>${_('External Id')}</td>
-                <td>${workflow.externalId or "-"}</td>
-              </tr>
-              <tr>
-                <td>${_('Start Time')}</td>
-                <td>${format_time(workflow.startTime)}</td>
-              </tr>
-              <tr>
-                <td>${_('Created Time')}</td>
-                <td>${format_time(workflow.createdTime)}</td>
-              </tr>
-              <tr>
-                <td>${_('End Time')}</td>
-                <td>${format_time(workflow.endTime)}</td>
-              </tr>
-              <tr>
-                <td>${_('Application Path')}</td>
-                <td>${hdfs_link(workflow.appPath)}
-                </td>
-              </tr>
-            </tbody>
-          </table>
-        </div>
-
-        ## Tab: Definition
-        <div class="tab-pane" id="definition">
-          <textarea id="definitionEditor">${ definition }</textarea>
-        </div>
-
-        ## Tab: Log
-        <div class="tab-pane" id="log">
-            <pre>${log}</pre>
-        </div>
-    </ul>
-  </div>
-</div>
-
-${configModal("appConfigModal", "Application Configuration", workflow.conf_dict)}
-
-<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>
-
-<script type="text/javascript">
-  $(document).ready(function() {
-
-    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();
-      }
-    });
-
-  });
-</script>
-
-${ commonfooter(messages) | n,unicode }

+ 74 - 260
apps/jobsub/src/jobsub/tests.py

@@ -24,176 +24,21 @@ try:
 except ImportError:
   import simplejson as json
 
+from nose.plugins.skip import SkipTest
 from nose.tools import assert_true, assert_false, assert_equal, assert_raises
 from django.contrib.auth.models import User
+from django.core.urlresolvers import reverse
 
 from desktop.lib.django_test_util import make_logged_in_client
 from desktop.lib.test_utils import grant_access
 from liboozie.oozie_api_test import OozieServerProvider
+from oozie.models import Workflow, Node, Action, Start, Kill, End, Link
 
-from jobsub import conf
-from jobsub.management.commands import jobsub_setup
-from jobsub.models import OozieDesign, OozieMapreduceAction, OozieStreamingAction, CheckForSetup
-from jobsub.parameterization import recursive_walk, find_variables, substitute_variables
 from django.template.defaultfilters import escapejs
 
 
 LOG = logging.getLogger(__name__)
 
-
-def test_recursive_walk():
-  def f(_):
-    f.leafs += 1
-  f.leafs = 0
-
-  # Test that we apply the function the right number of times
-  recursive_walk(f, [0,1,2])
-  assert_equal(3, f.leafs)
-  f.leafs = 0
-
-  recursive_walk(f, 1)
-  assert_equal(1, f.leafs)
-  f.leafs = 0
-
-  D = dict(a=2, b=3, c=dict(d=4, e=5))
-  Dorig = copy.deepcopy(D)
-  recursive_walk(f, D)
-  assert_equal(4, f.leafs)
-  assert_equal(Dorig, D, 'Object unexpectedly modified.')
-
-  # Test application and replacement
-  def square(x):
-    return x * x
-
-  assert_equal(dict(a=4, b=9, c=dict(d=16, e=25)), recursive_walk(square, D))
-
-def test_find_variables():
-  A = dict(one='$a',
-        two=dict(c='foo $b $$'),
-        three=['${foo}', 'xxx ${foo}'])
-  assert_equal(set(['a', 'b', 'foo']),
-    find_variables(A))
-
-def test_substitute_variables():
-  data = ['$greeting', dict(a='${where} $where')]
-  assert_equal(['hi', dict(a='there there')],
-    substitute_variables(data, dict(greeting='hi', where='there')))
-
-  data = [None, 'foo', dict(a=None)]
-  assert_equal(data, substitute_variables(data, dict()), 'Nothing to substitute')
-
-def test_job_design_cycle():
-  """
-  Tests for the "job design" CMS.
-  Submission requires a cluster, so that's separate.
-  """
-  c = make_logged_in_client()
-
-  # New should give us a form.
-  response = c.get('/jobsub/new_design/java')
-  assert_equal(1, response.content.count('action="/jobsub/new_design/java" method="POST"'))
-
-  # Streaming also:
-  response = c.get('/jobsub/new_design/streaming')
-  assert_equal(1, response.content.count('action="/jobsub/new_design/streaming" method="POST"'))
-
-  # Post back to create a new submission
-  design_count = OozieDesign.objects.count()
-  response = c.post('/jobsub/new_design/java', {
-     u'wf-name': [u'name-1'],
-     u'wf-description': [u'description name-1'],
-     u'action-args': [u'x y z'],
-     u'action-main_class': [u'MyClass'],
-     u'action-jar_path': [u'myfile.jar'],
-     u'action-java_opts': [u''],
-     u'action-archives': [u'[]'],
-     u'action-job_properties': [u'[]'],
-     u'action-files': [u'[]']})
-  assert_equal(design_count + 1, OozieDesign.objects.count())
-  job_id = OozieDesign.objects.get(name='name-1').id
-
-  response = c.post('/jobsub/new_design/mapreduce', {
-     u'wf-name': [u'name-2'],
-     u'wf-description': [u'description name-2'],
-     u'action-args': [u'x y z'],
-     u'action-jar_path': [u'myfile.jar'],
-     u'action-archives': [u'[]'],
-     u'action-job_properties': [u'[]'],
-     u'action-files': [u'[]']})
-
-  # Follow it
-  edit_url = '/jobsub/edit_design/%d' % job_id
-  response = c.get(edit_url)
-  assert_true('x y z' in response.content, response.content)
-
-  # Make an edit
-  response = c.post(edit_url, {
-     u'wf-name': [u'name-1'],
-     u'wf-description': [u'description name-1'],
-     u'action-args': [u'a b c'],
-     u'action-main_class': [u'MyClass'],
-     u'action-jar_path': [u'myfile.jar'],
-     u'action-java_opts': [u''],
-     u'action-archives': [u'[]'],
-     u'action-job_properties': [u'[]'],
-     u'action-files': [u'[]']})
-  assert_true('a b c' in c.get(edit_url).content)
-
-  # Try to post
-  response = c.post('/jobsub/new_design/java',
-    dict(name='test2', jarfile='myfile.jar', arguments='x y z', submit='Save'))
-  assert_false('This field is required' in response)
-
-  # Now check list
-  response = c.get('/jobsub/')
-  for design in OozieDesign.objects.all():
-    assert_true(escape(design.name) in response.content, response.content)
-
-  # With some filters
-  name1 = escape('name-1')
-  name2 = escape('name-2')
-
-  response = c.get('/jobsub/', dict(name='name-1'))
-  assert_true(name1 in response.content, response.content)
-  assert_false(name2 in response.content, response.content)
-
-  response = c.get('/jobsub/', dict(owner='doesnotexist'))
-  assert_false('doesnotexist' in response.content)
-
-  response = c.get('/jobsub/', dict(owner='test', name='name-1'))
-  assert_true(name1 in response.content, response.content)
-  assert_false(name2 in response.content, response.content)
-
-  response = c.get('/jobsub/', dict(name="name"))
-  assert_true(name1 in response.content, response.content)
-  assert_true(name2 in response.content, response.content)
-  assert_false('doesnotexist' in response.content, response.content)
-
-  # Combined filters
-  response = c.get('/jobsub/', dict(owner="test", name="name-2"))
-  assert_false(name1 in response.content, response.content)
-  assert_true(name2 in response.content, response.content)
-  assert_false('doesnotexist' in response.content, response.content)
-
-  # Try delete
-  job_id = OozieDesign.objects.get(name='name-1').id
-  response = c.post('/jobsub/delete_design/%d' % job_id)
-  assert_raises(OozieDesign.DoesNotExist, OozieDesign.objects.get, id=job_id)
-
-  # Let's make sure we can't delete other people's designs.
-  c.logout()
-  c = make_logged_in_client('test2', is_superuser=False)
-  grant_access('test2', 'test-grp', 'jobsub')
-
-  not_mine = OozieDesign.objects.get(name='name-2')
-  response = c.post('/jobsub/delete_design/%d' % not_mine.id)
-  assert_true('Permission denied.' in response.content, response.content)
-
-
-def escape(text):
-  return json.dumps(escapejs(text))
-
-
 class TestJobsubWithHadoop(OozieServerProvider):
 
   def setUp(self):
@@ -215,106 +60,75 @@ class TestJobsubWithHadoop(OozieServerProvider):
         LOG.warn("Received the following exception while change mode attempt %d of /tmp: %s" % (i, str(e)))
         time.sleep(1)
 
-  def tearDown(self):
-    OozieDesign.objects.all().delete()
-    CheckForSetup.objects.all().delete()
-
-  def test_jobsub_setup(self):
-    # User 'test' triggers the setup of the examples.
-    # 'hue' home will be deleted, the examples installed in the new one
-    # and 'test' will try to access them.
-    self.cluster.fs.setuser('jobsub_test')
-
-    username = 'hue'
-    home_dir = '/user/%s/' % username
-    finish = conf.REMOTE_DATA_DIR.set_for_testing('%s/jobsub' % home_dir)
-
-    try:
-      data_dir = conf.REMOTE_DATA_DIR.get()
-
-      if not jobsub_setup.Command().has_been_setup():
-        self.cluster.fs.setuser(self.cluster.fs.superuser)
-        if self.cluster.fs.exists(home_dir):
-          self.cluster.fs.rmtree(home_dir)
-
-        jobsub_setup.Command().handle()
-
-      self.cluster.fs.setuser('jobsub_test')
-      stats = self.cluster.fs.stats(home_dir)
-      assert_equal(stats['user'], username)
-      assert_equal(oct(stats['mode']), '040755') #04 because is a dir
-
-      stats = self.cluster.fs.stats(data_dir)
-      assert_equal(stats['user'], username)
-      assert_equal(oct(stats['mode']), '041777')
-
-      # Only examples should have been created by 'hue'
-      stats = self.cluster.fs.listdir_stats(data_dir)
-      sample_stats = filter(lambda stat: stat.user == username, stats)
-      assert_equal(len(sample_stats), 2)
-    finally:
-      finish()
-
-  def test_jobsub_setup_and_run_samples(self):
-    """
-    Merely exercises jobsub_setup, and then runs the sleep example.
-    """
-    if not jobsub_setup.Command().has_been_setup():
-      jobsub_setup.Command().handle()
-    self.cluster.fs.setuser('jobsub_test')
-
-    assert_equal(3, OozieDesign.objects.filter(owner__username='sample').count())
-    assert_equal(2, OozieMapreduceAction.objects.filter(ooziedesign__owner__username='sample').count())
-    assert_equal(1, OozieStreamingAction.objects.filter(ooziedesign__owner__username='sample').count())
+    self.design = self.create_design()
 
-    # Make sure sample user got created.
-    assert_equal(1, User.objects.filter(username='sample').count())
-
-    # Clone design
-    assert_equal(0, OozieDesign.objects.filter(owner__username='jobsub_test').count())
-    jobid = OozieDesign.objects.get(name='sleep_job', owner__username='sample').id
-
-    self.client.post('/jobsub/clone_design/%d' % jobid)
-    assert_equal(1, OozieDesign.objects.filter(owner__username='jobsub_test').count())
-    jobid = OozieDesign.objects.get(owner__username='jobsub_test').id
-
-    # And now submit and run the sleep sample
-    response = self.client.post('/jobsub/submit_design/%d' % jobid, {
-        'num_reduces': 1,
-        'num_maps': 1,
-        'map_sleep_time': 1,
-        'reduce_sleep_time': 1}, follow=True)
-
-    assert_true(sum([status in response.content for status in ('PREP', 'OK', 'DONE')]) > 0)
-    assert_true(str(jobid) in response.content)
-
-    oozie_job_id = response.context['jobid']
-    job = OozieServerProvider.wait_until_completion(oozie_job_id, timeout=120, step=1)
-    logs = OozieServerProvider.oozie.get_job_log(oozie_job_id)
-
-    assert_equal('SUCCEEDED', job.status, logs)
-
-
-    # Grep
-    n = OozieDesign.objects.filter(owner__username='jobsub_test').count()
-    jobid = OozieDesign.objects.get(name='grep_example').id
-
-    self.client.post('/jobsub/clone_design/%d' % jobid)
-    assert_equal(n + 1, OozieDesign.objects.filter(owner__username='jobsub_test').count())
-    jobid = OozieDesign.objects.get(owner__username='jobsub_test', name__contains='sleep_job').id
-
-    # And now submit and run the sleep sample
-    response = self.client.post('/jobsub/submit_design/%d' % jobid, {
-        'num_reduces': 1,
-        'num_maps': 1,
-        'map_sleep_time': 1,
-        'reduce_sleep_time': 1}, follow=True)
-
-    assert_true(sum([status in response.content for status in ('PREP', 'OK', 'DONE')]) > 0)
-    assert_true(str(jobid) in response.content)
-
-    oozie_job_id = response.context['jobid']
-    job = OozieServerProvider.wait_until_completion(oozie_job_id, timeout=60, step=1)
-    logs = OozieServerProvider.oozie.get_job_log(oozie_job_id)
-
-    assert_equal('SUCCEEDED', job.status, logs)
+  def tearDown(self):
+    Workflow.objects.all().delete()
+
+  def create_design(self):
+    response = self.client.post(reverse('jobsub.views.new_design',
+      kwargs={'node_type': 'mapreduce'}),
+      data={'name': 'sleep_job',
+            'description': '',
+            'node_type': 'mapreduce',
+            'jar_path': '/user/hue/oozie/workspaces/lib/hadoop-examples.jar',
+            'prepares': '[]',
+            'files': '[]',
+            'archives': '[]',
+            'job_properties': '[{\"name\":\"mapred.reduce.tasks\",\"value\":\"1\"},{\"name\":\"mapred.mapper.class\",\"value\":\"org.apache.hadoop.examples.SleepJob\"},{\"name\":\"mapred.reducer.class\",\"value\":\"org.apache.hadoop.examples.SleepJob\"},{\"name\":\"mapred.mapoutput.key.class\",\"value\":\"org.apache.hadoop.io.IntWritable\"},{\"name\":\"mapred.mapoutput.value.class\",\"value\":\"org.apache.hadoop.io.NullWritable\"},{\"name\":\"mapred.output.format.class\",\"value\":\"org.apache.hadoop.mapred.lib.NullOutputFormat\"},{\"name\":\"mapred.input.format.class\",\"value\":\"org.apache.hadoop.examples.SleepJob$SleepInputFormat\"},{\"name\":\"mapred.partitioner.class\",\"value\":\"org.apache.hadoop.examples.SleepJob\"},{\"name\":\"mapred.speculative.execution\",\"value\":\"false\"},{\"name\":\"sleep.job.map.sleep.time\",\"value\":\"0\"},{\"name\":\"sleep.job.reduce.sleep.time\",\"value\":\"${REDUCER_SLEEP_TIME}\"}]'},
+      HTTP_X_REQUESTED_WITH='XMLHttpRequest')
+    assert_equal(response.status_code, 200)
+    return Workflow.objects.all()[0]
+
+  def test_new_design(self):
+    # Ensure the following:
+    #   - creator is owner.
+    #   - workflow name and description are the same as action name and description.
+    #   - workflow has one action.
+    assert_false(self.design.managed)
+    assert_equal(4, Action.objects.filter(workflow=self.design).count())
+    assert_equal(1, Kill.objects.filter(workflow=self.design).count())
+    assert_equal(1, Start.objects.filter(workflow=self.design).count())
+    assert_equal(1, End.objects.filter(workflow=self.design).count())
+    assert_equal(4, Node.objects.filter(workflow=self.design).count())
+    assert_equal(3, Link.objects.filter(parent__workflow=self.design).count())
+
+  def test_save_design(self):
+    response = self.client.post(reverse('jobsub.views.save_design',
+      kwargs={'design_id': self.design.id}),
+      data={'name': 'mapreduce1',
+            'description': '',
+            'node_type': 'mapreduce',
+            'jar_path': '/user/hue/oozie/workspaces/lib/hadoop-examples.jar',
+            'prepares': '[]',
+            'files': '[{"name": "test", "dummy": ""}]',
+            'archives': '[]',
+            'job_properties': '[{\"name\":\"mapred.reduce.tasks\",\"value\":\"1\"},{\"name\":\"mapred.mapper.class\",\"value\":\"org.apache.hadoop.examples.SleepJob\"},{\"name\":\"mapred.reducer.class\",\"value\":\"org.apache.hadoop.examples.SleepJob\"},{\"name\":\"mapred.mapoutput.key.class\",\"value\":\"org.apache.hadoop.io.IntWritable\"},{\"name\":\"mapred.mapoutput.value.class\",\"value\":\"org.apache.hadoop.io.NullWritable\"},{\"name\":\"mapred.output.format.class\",\"value\":\"org.apache.hadoop.mapred.lib.NullOutputFormat\"},{\"name\":\"mapred.input.format.class\",\"value\":\"org.apache.hadoop.examples.SleepJob$SleepInputFormat\"},{\"name\":\"mapred.partitioner.class\",\"value\":\"org.apache.hadoop.examples.SleepJob\"},{\"name\":\"mapred.speculative.execution\",\"value\":\"false\"},{\"name\":\"sleep.job.map.sleep.time\",\"value\":\"0\"},{\"name\":\"sleep.job.reduce.sleep.time\",\"value\":\"${REDUCER_SLEEP_TIME}\"}]'},
+      HTTP_X_REQUESTED_WITH='XMLHttpRequest')
+    assert_equal(response.status_code, 200)
+    self.design = Workflow.objects.get(id=self.design.id)
+    assert_equal(self.design.start.get_child('to').get_full_node().files, '[{"name": "test", "dummy": ""}]')
+
+  def test_get_design(self):
+    response = self.client.get(reverse('jobsub.views.get_design',
+      kwargs={'design_id': self.design.id}),
+      HTTP_X_REQUESTED_WITH='XMLHttpRequest')
+    assert_equal(response.status_code, 200)
+
+  def test_delete_design(self):
+    assert_equal(1, Workflow.objects.count())
+    response = self.client.post(reverse('jobsub.views.delete_design',
+      kwargs={'design_id': self.design.id}),
+      follow=True,
+      HTTP_X_REQUESTED_WITH='XMLHttpRequest')
+    assert_equal(response.status_code, 200)
+    assert_equal(0, Workflow.objects.count())
+
+  def test_clone_design(self):
+    assert_equal(1, Workflow.objects.count())
+    response = self.client.post(reverse('jobsub.views.clone_design',
+      kwargs={'design_id': self.design.id}),
+      follow=True,
+      HTTP_X_REQUESTED_WITH='XMLHttpRequest')
+    assert_equal(response.status_code, 200)
+    assert_equal(2, Workflow.objects.count())

+ 11 - 17
apps/jobsub/src/jobsub/urls.py

@@ -18,25 +18,19 @@
 from django.conf.urls.defaults import patterns, url
 
 urlpatterns = patterns(
-  'jobsub',
+  'jobsub.views',
 
   # The base view is the "list" view, which we alias as /
-  url(r'^$', 'views.list_designs'),
+  url(r'^$', 'list_designs'),
 
-  url(r'^list_designs$', 'views.list_designs'),
-  url(r'^new_design/(?P<action_type>\w+)$', 'views.new_design'),
-  url(r'^delete_design/(?P<design_id>\d+)$', 'views.delete_design'),
-  url(r'^edit_design/(?P<design_id>\d+)$', 'views.edit_design'),
-  url(r'^clone_design/(?P<design_id>\d+)$', 'views.clone_design'),
-  url(r'^submit_design/(?P<design_id>\d+)$', 'views.submit_design'),
-  url(r'^design_parameters/(?P<design_id>\d+)$', 'views.get_design_params'),
+  # Actions: get, save, clone, delete, submit, new.
+  url(r'^designs$', 'list_designs'),
+  url(r'^designs/(?P<design_id>\d+)$', 'get_design'),
+  url(r'^designs/(?P<node_type>\w+)/new$', 'new_design'),
+  url(r'^designs/(?P<design_id>\d+)/save$', 'save_design'),
+  url(r'^designs/(?P<design_id>\d+)/clone$', 'clone_design'),
+  url(r'^designs/(?P<design_id>\d+)/delete$', 'delete_design'),
 
-  url(r'^job/(?P<jobid>[-\w]+)$', 'views.oozie_job'),
-  url(r'^list_history$', 'views.list_history'),
-
-  # Setup
-  url(r'^setup/$', 'views.setup'),
-
-  # Jasmine
-  url(r'^jasmine', 'views.jasmine'),
+  # Jasmine - Skip until rewritten
+  # url(r'^jasmine', 'views.jasmine'),
 )

+ 114 - 231
apps/jobsub/src/jobsub/views.py

@@ -14,7 +14,6 @@
 # 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.template.defaultfilters import escapejs
 """
 Views for JobSubmission.
 
@@ -34,8 +33,11 @@ import time as py_time
 
 from django.core import urlresolvers
 from django.shortcuts import redirect
+from django.template.defaultfilters import escapejs
+from django.utils.translation import ugettext as _
 
-from desktop.lib.django_util import render, extract_field_data
+from desktop.lib.django_util import render, render_json, extract_field_data
+from desktop.lib.exceptions import StructuredException
 from desktop.lib.exceptions_renderable import PopupException
 from desktop.lib.rest.http_client import RestException
 from desktop.log.access import access_warn
@@ -43,99 +45,16 @@ from desktop.log.access import access_warn
 from hadoop.fs.exceptions import WebHdfsException
 from liboozie.oozie_api import get_oozie
 
-from jobsub import models, submit
-from jobsub.management.commands import jobsub_setup
-import jobsub.forms
+from oozie.models import Workflow
+from oozie.forms import design_form_by_type
+from oozie.utils import model_to_dict, format_dict_field_values, format_field_value,\
+                        sanitize_node_dict, JSON_FIELDS
 
-from django.utils.translation import ugettext as _
+from jobsub.management.commands import jobsub_setup
 
 
 LOG = logging.getLogger(__name__)
 
-def oozie_job(request, jobid):
-  """View the details about this job."""
-  try:
-    workflow = get_oozie().get_job(jobid)
-    _check_permission(request, workflow.user,
-                      _("Access denied: view job %(id)s.") % {'id': jobid},
-                      allow_root=True)
-    # Accessing log and definition will trigger Oozie API calls
-    log = workflow.log
-    definition = workflow.definition
-  except RestException, ex:
-    raise PopupException(_("Error accessing Oozie job %(id)s.") % {'id': jobid},
-                         detail=ex.message)
-
-  # Cross reference the submission history (if any)
-  design_link = None
-  try:
-    history_record = models.JobHistory.objects.get(job_id=jobid)
-    design = history_record.design
-    if design.owner == request.user:
-      design_link = urlresolvers.reverse(jobsub.views.edit_design,
-                                         kwargs={'design_id': design.id})
-  except models.JobHistory.DoesNotExist, ex:
-    pass
-
-  return render('workflow.mako', request, {
-    'workflow': workflow,
-    'design_link': design_link,
-    'definition': definition,
-    'log': log,
-    'jobid': jobid,
-  })
-
-
-def list_history(request):
-  """
-  List the job submission history. Normal users can only look at their
-  own submissions.
-  """
-  history = models.JobHistory.objects
-
-  if not request.user.is_superuser:
-    history = history.filter(owner=request.user)
-  history = history.order_by('-submission_date')
-
-  return render('list_history.mako', request, {
-    'history': history,
-  })
-
-
-def new_design(request, action_type):
-  form = jobsub.forms.design_form_by_type(action_type)
-
-  if request.method == 'POST':
-    form.bind(request.POST)
-
-    if form.is_valid():
-      action = form.action.save(commit=False)
-      action.action_type = action_type
-      action.save()
-
-      design = form.wf.save(commit=False)
-      design.root_action = action
-      design.owner = request.user
-      design.save()
-
-      return redirect(urlresolvers.reverse(list_designs))
-  else:
-    form.bind()
-
-  return _render_design_edit(request, form, action_type, _STD_PROPERTIES_JSON)
-
-
-def _render_design_edit(request, form, action_type, properties_hint):
-  return render('edit_design.mako', request, {
-    'form': form,
-    'action': request.path,
-    'action_type': action_type,
-    'properties': extract_field_data(form.action['job_properties']),
-    'files': extract_field_data(form.action['files']),
-    'archives': extract_field_data(form.action['archives']),
-    'properties_hint': properties_hint,
-  })
-
 
 def list_designs(request):
   '''
@@ -144,7 +63,7 @@ def list_designs(request):
     owner       - Substring filter by owner field
     name        - Substring filter by design name field
   '''
-  data = models.OozieDesign.objects
+  data = Workflow.objects.filter(managed=False)
   owner = request.GET.get('owner', '')
   name = request.GET.get('name', '')
   if owner:
@@ -153,9 +72,6 @@ def list_designs(request):
       data = data.filter(name__icontains=name)
   data = data.order_by('-last_modified')
 
-  show_install_examples = \
-      request.user.is_superuser and not jobsub_setup.Command().has_been_setup()
-
   designs = []
   for design in data:
       ko_design = {
@@ -163,32 +79,28 @@ def list_designs(request):
           'owner': escapejs(design.owner.username),
           'name': escapejs(design.name),
           'description': escapejs(design.description),
-          'type': design.root_action.action_type,
+          'node_type': design.start.get_child('to').node_type,
           'last_modified': py_time.mktime(design.last_modified.timetuple()),
-          'url_params': urlresolvers.reverse(jobsub.views.get_design_params, kwargs={'design_id': design.id}),
-          'url_submit': urlresolvers.reverse(jobsub.views.submit_design, kwargs={'design_id': design.id}),
-          'url_edit': urlresolvers.reverse(jobsub.views.edit_design, kwargs={'design_id': design.id}),
-          'url_delete': urlresolvers.reverse(jobsub.views.delete_design, kwargs={'design_id': design.id}),
-          'url_clone': urlresolvers.reverse(jobsub.views.clone_design, kwargs={'design_id': design.id}),
-          'can_submit': request.user.username == design.owner.username,
-          'can_delete': request.user.is_superuser or request.user.username == design.owner.username
+          'editable': design.owner.id == request.user.id
       }
       designs.append(ko_design)
 
-  return render("list_designs.mako", request, {
-    'currentuser': request.user,
-    'owner': owner,
-    'name': name,
-    'designs': json.dumps(designs),
-    'show_install_examples': show_install_examples,
-  })
+  if request.is_ajax():
+    return render_json(designs)
+  else:
+    return render("designs.mako", request, {
+      'currentuser': request.user,
+      'owner': owner,
+      'name': name,
+      'designs': json.dumps(designs)
+    })
 
 def _get_design(design_id):
   """Raise PopupException if design doesn't exist"""
   try:
-    return models.OozieDesign.objects.get(pk=design_id)
-  except models.OozieDesign.DoesNotExist:
-    raise PopupException("Job design not found")
+    return Workflow.objects.get(pk=design_id)
+  except Workflow.DoesNotExist:
+    raise PopupException(_("Workflow not found"))
 
 def _check_permission(request, owner_name, error_msg, allow_root=False):
   """Raise PopupException if user doesn't have permission to modify the design"""
@@ -198,139 +110,110 @@ def _check_permission(request, owner_name, error_msg, allow_root=False):
     access_warn(request, error_msg)
     raise PopupException(_("Permission denied. You are not the owner."))
 
-
 def delete_design(request, design_id):
-  if request.method == 'POST':
-    try:
-      design_obj = _get_design(design_id)
-      _check_permission(request, design_obj.owner.username,
-                        _("Access denied: delete design %(id)s.") % {'id': design_id},
-                        allow_root=True)
-      design_obj.root_action.delete()
-      design_obj.delete()
-
-      submit.Submission(design_obj, request.fs).remove_deployment_dir()
-    except models.OozieDesign.DoesNotExist:
-      LOG.error("Trying to delete non-existent design (id %s)" % (design_id,))
-      raise PopupException(_("Workflow not found."))
-
-  return redirect(urlresolvers.reverse(list_designs))
-
-
-def edit_design(request, design_id):
-  design_obj = _get_design(design_id)
-  _check_permission(request, design_obj.owner.username,
-                    _("Access denied: edit design %(id)s.") % {'id': design_id})
-
-  if request.method == 'POST':
-    form = jobsub.forms.design_form_by_instance(design_obj, request.POST)
-    if form.is_valid():
-      form.action.save()
-      form.wf.save()
-      return redirect(urlresolvers.reverse(list_designs))
-  else:
-    form = jobsub.forms.design_form_by_instance(design_obj)
+  if request.method != 'POST':
+    raise StructuredException(code="METHOD_NOT_ALLOWED_ERROR", message=_('Must be POST request.'), error_code=405)
 
-  return _render_design_edit(request,
-                               form,
-                               design_obj.root_action.action_type,
-                               _STD_PROPERTIES_JSON)
+  try:
+    workflow = _get_design(design_id)
+    _check_permission(request, workflow.owner.username,
+                      _("Access denied: delete workflow %(id)s.") % {'id': design_id},
+                      allow_root=True)
+    Workflow.objects.destroy(workflow, request.fs)
 
+  except Workflow.DoesNotExist:
+    LOG.error("Trying to delete non-existent workflow (id %s)" % (design_id,))
+    raise StructuredException(code="NOT_FOUND", message=_('Could not find design.'), error_code=404)
 
-def clone_design(request, design_id):
-  design_obj = _get_design(design_id)
-  clone = design_obj.clone(request.user)
-  return redirect(urlresolvers.reverse(edit_design, kwargs={'design_id': clone.id}))
+  return render_json({})
 
 
-def get_design_params(request, design_id):
-  """
-  Return the parameters found in the design as a json dictionary of
-    { param_key : label }
-  This expects an ajax call.
-  """
-  design_obj = _get_design(design_id)
-  _check_permission(request, design_obj.owner.username,
-                    _("Access denied: design parameters %(id)s.") % {'id': design_id})
-  params = design_obj.find_parameters()
-  params_with_labels = dict((p, p.upper()) for p in params)
-  return render('dont_care_for_ajax', request, { 'params': params_with_labels })
+def get_design(request, design_id):
+  workflow = _get_design(design_id)
+  _check_permission(request, workflow.owner.username, _("Access denied: edit design %(id)s.") % {'id': design_id})
+  node = workflow.start.get_child('to')
+  node_dict = model_to_dict(node)
+  node_dict['id'] = design_id
+  for key in node_dict:
+    if key not in JSON_FIELDS:
+      node_dict[key] = escapejs(node_dict[key])
+  node_dict['editable'] = True
+  return render_json(node_dict);
+
+
+def save_design(request, design_id):
+  workflow = _get_design(design_id)
+  _check_permission(request, workflow.owner.username, _("Access denied: edit design %(id)s.") % {'id': workflow.id})
+
+  ActionForm = design_form_by_type(request.POST.get('node_type', None), request.user, workflow)
+  form = ActionForm(request.POST)
+
+  if not form.is_valid():
+    raise StructuredException(code="INVALID_REQUEST_ERROR", message=_('Error saving design'), data={'errors': form.errors}, error_code=400)
+
+  data = format_dict_field_values(request.POST.copy())
+  sanitize_node_dict(data)
+  workflow.name = data['name']
+  workflow.description = data['description']
+  node = workflow.start.get_child('to').get_full_node()
+  node_id = node.id
+  for key in data:
+    setattr(node, key, data[key])
+  node.id = node_id
+  node.pk = node_id
+  node.save()
+  workflow.save()
 
+  data['id'] = workflow.id
+  return render_json(data);
 
-def submit_design(request, design_id):
+
+def new_design(request, node_type):
   """
-  Submit a workflow to Oozie.
-  The POST data should contain parameter values.
+  Designs are the interpolation of Workflows and a single action.
+  Save ``name`` and ``description`` of workflows.
+  Also, use ``id`` of workflows.
   """
   if request.method != 'POST':
-    raise PopupException(_('Use a POST request to submit a design.'))
+    raise StructuredException(code="METHOD_NOT_ALLOWED_ERROR", message=_('Must be POST request.'), error_code=405)
 
-  design_obj = _get_design(design_id)
-  _check_permission(request, design_obj.owner.username,
-                    _("Access denied: submit design %(id)s.") % {'id': design_id})
+  workflow = Workflow.objects.new_workflow(request.user)
+  ActionForm = design_form_by_type(node_type, request.user, workflow)
+  form = ActionForm(request.POST)
 
-  # Expect the parameter mapping in the POST data
-  design_obj.bind_parameters(request.POST)
+  if not form.is_valid():
+    raise StructuredException(code="INVALID_REQUEST_ERROR", message=_('Error saving design'), data={'errors': form.errors}, error_code=400)
 
-  try:
-    submission = submit.Submission(design_obj, request.fs)
-    jobid = submission.run()
-  except RestException, ex:
-    detail = ex.message
-    if 'urlopen error' in ex.message:
-      detail = '%s: %s' % (_('The Oozie server is not running'), detail)
-    raise PopupException(_("Error submitting design %(id)s.") % {'id': design_id}, detail=detail)
-  # Save the submission record
-  job_record = models.JobHistory(owner=request.user,
-                                 job_id=jobid,
-                                 design=design_obj)
-  job_record.save()
-
-  # Show oozie job info
-  return redirect(urlresolvers.reverse(oozie_job, kwargs={'jobid': jobid}))
-
-
-def setup(request):
-  """Installs jobsub examples."""
-  if request.method != "POST":
-    raise PopupException(_('Use a POST request to install the examples.'))
-  try:
-    # Warning: below will modify fs.user
-    jobsub_setup.Command().handle_noargs()
-  except WebHdfsException, e:
-    raise PopupException(_('The examples could not be installed.'), detail=e)
-  return redirect(urlresolvers.reverse(list_designs))
+  workflow.managed = False
+  workflow.save()
+  Workflow.objects.initialize(workflow, request.fs)
+  action = form.save(commit=False)
+  action.workflow = workflow
+  action.node_type = node_type
+  action.save()
+  workflow.start.add_node(action)
+  action.add_node(workflow.end)
+  workflow.name = request.POST.get('name')
+  workflow.description = request.POST.get('description')
+  workflow.save()
 
-def jasmine(request):
-  return render('jasmine.mako', request, None)
+  data = format_dict_field_values(request.POST.copy())
+  data['id'] = workflow.id
+  return render_json(data)
 
 
-# See http://wiki.apache.org/hadoop/JobConfFile
-_STD_PROPERTIES = [
-  'mapred.input.dir',
-  'mapred.output.dir',
-  'mapred.job.name',
-  'mapred.job.queue.name',
-  'mapred.mapper.class',
-  'mapred.reducer.class',
-  'mapred.combiner.class',
-  'mapred.partitioner.class',
-  'mapred.map.tasks',
-  'mapred.reduce.tasks',
-  'mapred.input.format.class',
-  'mapred.output.format.class',
-  'mapred.input.key.class',
-  'mapred.input.value.class',
-  'mapred.output.key.class',
-  'mapred.output.value.class',
-  'mapred.mapoutput.key.class',
-  'mapred.mapoutput.value.class',
-  'mapred.combine.buffer.size',
-  'mapred.min.split.size',
-  'mapred.speculative.execution',
-  'mapred.map.tasks.speculative.execution',
-  'mapred.reduce.tasks.speculative.execution',
-  'mapred.queue.default.acl-administer-jobs',
-]
-
-_STD_PROPERTIES_JSON = json.dumps(_STD_PROPERTIES)
+def clone_design(request, design_id):
+  if request.method != 'POST':
+    raise StructuredException(code="METHOD_NOT_ALLOWED_ERROR", message=_('Must be POST request.'), error_code=405)
+
+  workflow = _get_design(design_id)
+  clone = workflow.clone(request.fs, request.user)
+  cloned_action = clone.start.get_child('to')
+  cloned_action.name = clone.name
+  cloned_action.save()
+
+  return get_design(request, clone.id)
+
+
+def jasmine(request):
+  return render('jasmine.mako', request, None)

+ 3 - 0
apps/jobsub/static/css/jobsub.css

@@ -0,0 +1,3 @@
+li.error {
+	list-style: none;
+}

+ 206 - 0
apps/jobsub/static/js/jobsub.js

@@ -0,0 +1,206 @@
+// 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.
+
+
+//// Helper methods
+var format = (function(){
+  // Date.format follows python datetime formatting.
+  // @see http://docs.python.org/2/library/datetime.html#strftime-and-strptime-behavior
+  var WEEKDAYS = ['Sunday', 'Monday', 'Tuesday', 'Wednesday',
+                  'Thursday', 'Friday', 'Saturday'];
+  var MONTH = ['January', 'February', 'March', 'April',
+               'May', 'June', 'July', 'August',
+               'September', 'October', 'November', 'December'];
+  return function(format) {
+    var self = this;
+
+    // 'esc' declares whether or not we found an escape character.
+    var esc = false;
+    var str = '';
+    for (var i = 0; i < format.length; ++i) {
+      if (esc) {
+        // Escape character followed by these characters
+        // is a formatting option.
+        switch(format[i]) {
+          case 'a':
+            str += WEEKDAYS[self.getDay()].substring(0,3);
+          break;
+          case 'A':
+            str += WEEKDAYS[self.getDay()];
+          break;
+          case 'b':
+            str += MONTH[self.getMonth()].substring(0,3);
+          break;
+          case 'B':
+            str += MONTH[self.getMonth()];
+          break;
+          case 'c':
+            str += self.toLocaleString();
+          break;
+          case 'd':
+            var tmp = '00' + self.getDate();
+            str += tmp.substring(tmp.length - 2, tmp.length);
+          break;
+          case 'f':
+            // TODO: Microsecond as a decimal number [0,999999], zero-padded on the left
+          break;
+          case 'H':
+            var tmp = '00' + self.getHours();
+            str += tmp.substring(tmp.length - 2, tmp.length);
+          break;
+          case 'I':
+            var hour = self.getHours() % 12;
+            var tmp = '00' + ((hour == 0) ? '12' : hour);
+            str += tmp.substring(tmp.length - 2, tmp.length);
+          break;
+          case 'j':
+            // TODO: Day of the year as a decimal number [001,366].
+          break;
+          case 'm':
+            var tmp = '00' + (self.getMonth() + 1);
+            str += tmp.substring(tmp.length - 2, tmp.length);
+          case 'M':
+            var tmp = '00' + self.getMinutes();
+            str += tmp.substring(tmp.length - 2, tmp.length);
+          break;
+          case 'p':
+            str += (self.getHours() > 11) ? 'PM' : 'AM';
+          break;
+          case 'S':
+            var tmp = '00' + self.getSeconds();
+            str += tmp.substring(tmp.length - 2, tmp.length);
+          break;
+          case 'U':
+            // TODO: Week number of the year [0,53]. Sunday starts.
+          break;
+          case 'w':
+            str += self.getDay();
+          break;
+          case 'W':
+            // TODO: Week number of the year [0,53]. Monday starts.
+          break;
+          case 'x':
+            str += self.toLocaleDateString();
+          break;
+          case 'X':
+            str += self.toLocaleTimeString();
+          break;
+          case 'y':
+            str += self.getFullYear() % 100;
+          break;
+          case 'Y':
+            str += self.getFullYear();
+          break;
+          case 'z':
+            // TODO: UTC offset in the form +HHMM or -HHMM (empty string if the the object is naive)
+          break;
+          case 'Z':
+            // Time zone name (empty string if the object is naive).
+          break;
+          case '%':
+            str += '%'
+          break
+          default:
+            // Bad escape.
+            str += '%' + format[i];
+          break;
+        }
+        esc = false;
+      } else {
+        switch(format[i]) {
+          case '%':
+            esc = true;
+          break;
+          default:
+            str += format[i];
+          break;
+        }
+      }
+    }
+
+    return str;
+  };
+})();
+Date.prototype.format = format;
+
+function showSection(section) {
+  $('.section').hide();
+  $('#' + section).show();
+  $(window).scrollTop(0);
+
+  // Filechooser.
+  $(".pathChooserKo").each(function(){
+    var self = $(this);
+    self.after(getFileBrowseButton(self));
+  });
+}
+
+function getFileBrowseButton(inputElement) {
+  return $("<button>").addClass("btn").addClass("fileChooserBtn").text("..").click(function(e){
+    e.preventDefault();
+    $("#fileChooserModal").jHueFileChooser({
+      initialPath: inputElement.val(),
+      onFileChoose: function(filePath) {
+        inputElement.val(filePath);
+        inputElement.change();
+        $("#chooseFile").modal("hide");
+      },
+      createFolder: false
+    });
+    $("#chooseFile").modal("show");
+  })
+}
+
+function addFileBrowseButton() {
+  // Filechooser.
+  $(".pathChooserKo").each(function(){
+    var self = $(this);
+    if (!self.siblings().hasClass('fileChooserBtn')) {
+      self.after(getFileBrowseButton(self));
+    }
+  });
+}
+
+//// Event handling.
+$(document).bind('add.file.workflow', addFileBrowseButton);
+$(document).bind('remove.file.workflow', addFileBrowseButton);
+$(document).bind('add.property.workflow', addFileBrowseButton);
+$(document).bind('remove.property.workflow', addFileBrowseButton);
+$(document).bind('add.archive.workflow', addFileBrowseButton);
+$(document).bind('remove.archive.workflow', addFileBrowseButton);
+$(document).bind('add.arg.workflow', addFileBrowseButton);
+$(document).bind('add.argument.workflow', addFileBrowseButton);
+$(document).bind('add.envvar.workflow', addFileBrowseButton);
+$(document).bind('add.param.workflow', addFileBrowseButton);
+$(document).bind('remove.param.workflow', addFileBrowseButton);
+$(document).bind('add.prepare_delete.workflow', addFileBrowseButton);
+$(document).bind('add.prepare_mkdir.workflow', addFileBrowseButton);
+$(document).bind('remove.prepare.workflow', addFileBrowseButton);
+$(document).bind('add.delete.workflow', addFileBrowseButton);
+$(document).bind('remove.delete.workflow', addFileBrowseButton);
+$(document).bind('add.mkdir.workflow', addFileBrowseButton);
+$(document).bind('remove.mkdir.workflow', addFileBrowseButton);
+$(document).bind('add.chmod.workflow', addFileBrowseButton);
+$(document).bind('remove.chmod.workflow', addFileBrowseButton);
+$(document).bind('add.move.workflow', addFileBrowseButton);
+$(document).bind('remove.move.workflow', addFileBrowseButton);
+$(document).bind('add.touchz.workflow', addFileBrowseButton);
+$(document).bind('remove.touchz.workflow', addFileBrowseButton);
+$(document).bind('error.design', addFileBrowseButton);
+$(document).bind('save.design', function() {designs.load();});
+$(document).bind('delete.design', function() {designs.load();});
+$(document).bind('clone.design', function() {designs.load();});
+$(document).bind('load.designs', function() { routie('list-designs'); });

+ 327 - 86
apps/jobsub/static/js/jobsub.ko.js

@@ -14,107 +14,348 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
+/**
+ * Design representation
+ */
+var Design = (function($, ko, NodeFields) {
+  var module = function(options) {
+    var self = this;
 
-var Design = function (design) {
-    return {
-        id:design.id,
-        owner:design.owner,
-        name:design.name,
-        description:design.description,
-        type:design.type,
-        lastModifiedMillis:design.last_modified,
-        lastModified:moment.unix(design.last_modified).format("MMMM DD, YYYY hh:mm a"),
-        paramsUrl:design.url_params,
-        submitUrl:design.url_submit,
-        editUrl:design.url_edit,
-        deleteUrl:design.url_delete,
-        cloneUrl:design.url_clone,
-        canSubmit:design.can_submit,
-        canDelete:design.can_delete,
-        selected:ko.observable(false),
-        handleSelect:function (row, e) {
-            this.selected(!this.selected());
-        }
-    }
-}
+    self.options = {};
+    self.model = {};
 
+    self.initialize(options);
+  };
 
-var JobSubModel = function (designs) {
+  // NodeFields is defined in oozie/static/js/workflow.node-fields.js.
+  // It provides the more advanced field manipulation for the fields
+  // that hava JSON representation.
+  $.extend(module.prototype, NodeFields, {
+    initialize: function(options) {
+      var self = this;
 
-    var self = this;
+      self.options = $.extend(self.options, options);
+      self.model = options.model;
 
-    self.designs = ko.observableArray(ko.utils.arrayMap(designs, function (design) {
-        return new Design(design);
-    }));
+      self.model.errors = self.model.errors || {};
+      for(var key in self.model) {
+        switch(key) {
+          case 'initialize':
+          case 'toString':
+          case 'errors':
+          break;
+          default:
+            if (!(key in self.model.errors)) {
+              self.model.errors[key] = [];
+            }
+          break;
+        }
+      }
+
+      // @see http://knockoutjs.com/documentation/plugins-mapping.html
+      // MAPPING_OPTIONS comes from /oozie/static/js/models.js
+      // We don't update the observed object using ko.mapping because
+      // the plugin does not work with mixed objects.
+      ko.mapping.fromJS(self.model, MAPPING_OPTIONS, self);
 
-    self.isLoading = ko.observable(true);
+      // hack on '<key>ErrorClass' and '<key>Condition'.
+      $.each(self.__ko_mapping__, function(key, enabled) {
+        if (ko.isObservable(self[key])) {
+          self[key+'_condition'] = ko.computed(function() {
+            return self[key]().length > 0;
+          });
+          self[key+'_error_class'] = ko.computed(function() {
+            return ( self.errors[key]().length > 0 ) ? 'control-group error' : 'control-group';
+          });
+        }
+      });
 
-    self.allSelected = ko.observable(false);
+      if (!('is_dirty' in self)) {
+        self.is_dirty = ko.observable(true);
+      }
 
-    self.selectedDesigns = ko.computed(function () {
-        return ko.utils.arrayFilter(self.designs(), function (design) {
-            return design.selected();
+      if (!('new' in self)) {
+        self.new = ko.computed(function() {
+          return !self.id();
         });
-    }, self);
+      }
 
-    self.selectedDesign = ko.computed(function () {
-        return self.selectedDesigns()[0];
-    }, self);
+      $(document).trigger('initialize.design', [options, self]);
+    },
+    request: function(url, options) {
+      var self = this;
 
-    self.selectAll = function () {
-        self.allSelected(!self.allSelected());
-        ko.utils.arrayForEach(self.designs(), function (design) {
-            design.selected(self.allSelected());
-        });
-        return true;
-    };
+      var request = $.extend({
+        url: url,
+        dataType: 'json',
+        type: 'GET',
+        success: $.noop,
+        error: $.noop
+      }, options || {});
+
+      $.ajax(request);
+    },
+    load: function(options) {
+      var self = this;
+      var options = $.extend({
+        success: function(data) {
+          self.is_dirty(false);
+          $(document).trigger('load.design', [options, data]);
+        }
+      }, options);
+      this.request('/jobsub/designs/' + self.id(), options);
+    },
+    save: function(options) {
+      // First try to save, then update error list if fail.
+      // Response should be json object. IE: {data: {errors: {files: ['example', ...], ... }}}
+      var self = this;
+      var model_dict = {};
+      $.each(ko.mapping.toJS(self), function(key, value) {
+        if (key != 'errors') {
+          model_dict[key] = ko.utils.unwrapObservable(value);
+        }
+      });
+      var data = normalize_model_fields($.parseJSON(JSON.stringify(model_dict)));
+      var options = $.extend({
+        type: 'POST',
+        data: data,
+        error: function(xhr) {
+          var response = $.parseJSON(xhr.responseText);
+          if (response) {
+            var model = ko.mapping.toJS(self);
+            $.extend(model.errors, response.data.errors);
+            ko.mapping.fromJS(model, self);
+            $(document).trigger('error.design', [options, data]);
+          }
+        },
+        success: function(data) {
+          $(document).trigger('save.design', [options, data]);
+        }
+      }, options);
+      self.request((self.new()) ? '/jobsub/designs/'+self.node_type()+'/new' : '/jobsub/designs/'+self.id()+'/save', options);
+    },
+    clone: function(options) {
+      var self = this;
+      var options = $.extend({
+        type: 'POST',
+        success: function(data) {
+          $(document).trigger('clone.design', [options, data]);
+        }
+      }, options);
+      this.request('/jobsub/designs/' + self.id() + '/clone', options);
+    },
+    delete: function(options) {
+      var self = this;
+      var options = $.extend({
+        type: 'POST',
+        success: function(data) {
+          $(document).trigger('delete.design', [options, data]);
+        }
+      }, options);
+      this.request('/jobsub/designs/' + self.id() + '/delete', options);
+    }
+  });
 
-    self.cloneDesign = function () {
-        location.href = self.selectedDesign().cloneUrl;
+  return module;
+})($, ko, NodeFields);
+
+/**
+ * List of designs
+ */
+var Designs = (function($, ko, NodeModelChooser) {
+  var module = function(options) {
+    var self = this;
+
+    self.options = options || {
+      models: []
     };
 
-    self.editDesign = function (design) {
-        if (design.editUrl == null) {
-            design = self.selectedDesign();
+    self.temporary = ko.observable();
+
+    self.designs = ko.observableArray([]);
+    self.selectedDesignObjects = ko.computed(function() {
+      var selected = [];
+      $.each(self.designs(), function(index, designObject) {
+        if (designObject.selected()) {
+          selected.push(designObject);
         }
-        if (design != null && design.canSubmit) {
-            location.href = design.editUrl;
+      });
+      return selected;
+    });
+    self.selectedDesignObject = ko.computed(function () {
+      return self.selectedDesignObjects()[0];
+    });
+    self.selectedDesign = ko.computed(function() {
+      if (self.selectedDesignObject()) {
+        return self.selectedDesignObject().design();
+      } else {
+        return null;
+      }
+    });
+    self.selectedIndex = ko.computed(function() {
+      var selected = -1;
+      $.each(self.designs(), function(index, designObject) {
+        if (selected == -1 && designObject.selected()) {
+          selected = index;
         }
-    };
+      });
+      return selected;
+    });
+    self.allSelected = ko.computed(function() {
+      return self.selectedDesignObjects().length == self.designs().length;
+    });
 
-    self.deleteDesign = function () {
-        $("#deleteWfForm").attr("action", self.selectedDesign().deleteUrl);
-        $("#deleteWfMessage").text(deleteMessage.replace("##PLACEHOLDER##", self.selectedDesign().name));
-        $("#deleteWf").modal("show");
-    };
+    self.initialize(options);
+  };
 
-    self.submitDesign = function () {
-        $("#submitWfForm").attr("action", self.selectedDesign().submitUrl);
-        $("#submitWfMessage").text(submitMessage.replace("##PLACEHOLDER##", self.selectedDesign().name));
-        // We will show the model form, but disable the submit button
-        // until we've finish loading the parameters via ajax.
-        $("#submitBtn").attr("disabled", "disabled");
-        $("#submitWf").modal("show");
-
-        $.get(self.selectedDesign().paramsUrl, function (data) {
-            var params = data["params"]
-            var container = $("#param-container");
-            container.empty();
-            for (key in params) {
-                if (!params.hasOwnProperty(key)) {
-                    continue;
-                }
-                container.append(
-                    $("<div/>").addClass("clearfix")
-                        .append($("<label/>").text(params[key]))
-                        .append(
-                        $("<div/>").addClass("input")
-                            .append($("<input/>").attr("name", key).attr("type", "text"))
-                    )
-                )
-            }
-            // Good. We can submit now.
-            $("#submitBtn").removeAttr("disabled");
-        }, "json");
-    };
-};
+  $.extend(module.prototype, {
+    initialize: function(options) {
+      var self = this;
+
+      self.options = $.extend(self.options, options);
+
+      self.designs.removeAll();
+      self.createDesigns(self.options.models);
+      self.temporary({
+        design: ko.observable(null),
+        selected: ko.observable(false),
+        template: ko.observable(null)
+      })
+      self.deselectAll();
+
+      $(document).trigger('initialize.designs', [options, self]);
+    },
+    load: function(options) {
+      // Fetch designs from backend.
+      var self = this;
+      var request = $.extend({
+        url: '/jobsub/designs',
+        dataType: 'json',
+        type: 'GET',
+        success: function(data) {
+          $(document).trigger('load.designs', [options, data]);
+          self.initialize({models: data});
+        },
+        error: $.noop
+      }, options || {});
+      $.ajax(request);
+    },
+    ensureListFields: function(model) {
+      if (!('name' in model)) {
+        model.name = '';
+      }
+      if (!('description' in model)) {
+        model.description = '';
+      }
+      if (!('owner' in model)) {
+        model.owner = '';
+      }
+      if (!('last_modified' in model)) {
+        model.last_modified = 0;
+      }
+      return model;
+    },
+    createDesign: function(model) {
+      var self = this;
+      var NodeModel = NodeModelChooser(model.node_type);
+      var node_model = new NodeModel(self.ensureListFields(model));
+      var design = new Design({model: node_model});
+      return design;
+    },
+    createDesigns: function(models) {
+      var self = this;
+      $.each(models, function(index, model) {
+        self.designs.push({
+          design: ko.observable(self.createDesign(model)),
+          selected: ko.observable(false),
+          template: ko.observable(model.node_type)
+        });
+      });
+    },
+    toggleSelect: function(index) {
+      var self = this;
+      self.designs()[index].selected(!self.designs()[index].selected());
+    },
+    select: function(index) {
+      var self = this;
+      self.designs()[index].selected(true);
+    },
+    toggleSelectAll: function() {
+      var self = this;
+      if (self.allSelected()) {
+        self.deselectAll();
+      } else {
+        self.selectAll();
+      }
+    },
+    selectAll: function() {
+      var self = this;
+      $.each(self.designs(), function(index, value) {
+        value.selected(true);
+      });
+    },
+    deselectAll: function() {
+      var self = this;
+      $.each(self.designs(), function(index, value) {
+        value.selected(false);
+      });
+    },
+
+    //// Design delegation
+    newDesign: function(node_type) {
+      var self = this;
+      var design = self.createDesign({
+        id: null,
+        node_type: node_type
+      });
+      // Reversing the order of the next two statements may cause KO to break.
+      self.temporary().template(node_type);
+      self.temporary().design(design);
+      $(document).trigger('new.design', [design]);
+    },
+    saveDesign: function(data, event) {
+      var self = this;
+      self.temporary().design().save();
+    },
+    cloneDesigns: function() {
+      var self = this;
+      $.each(self.selectedDesignObjects(), function(index, designObject) {
+        designObject.design().clone();
+      });
+    },
+    deleteDesigns: function() {
+      var self = this;
+      $.each(self.selectedDesignObjects(), function(index, designObject) {
+        designObject.design().delete();
+      });
+    },
+    editDesign: function(index) {
+      var self = this;
+      if (self.selectedDesignObject()) {
+        var design = self.selectedDesignObject().design();
+        if (design.is_dirty()) {
+          $(document).one('load.design', function(e, options, data) {
+            design.initialize({model: data});
+            self.temporary().design(design);
+            self.temporary().template(self.selectedDesignObject().template());
+            $(document).trigger('edit.design', [design, data]);
+          });
+          design.load();
+        } else {
+          self.temporary().design(design);
+          self.temporary().template(self.selectedDesignObject().template());
+          $(document).trigger('edit.design', [design, data]);
+        }
+      }
+    },
+    closeDesign: function() {
+      var self = this;
+      self.temporary().design(null);
+      self.temporary().template(null);
+    }
+  });
+
+  return module;
+})($, ko, nodeModelChooser);
+
+var designs = new Designs({models: []});

+ 97 - 0
apps/jobsub/static/js/jobsub.templates.js

@@ -0,0 +1,97 @@
+// 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.
+
+
+//// Load templates
+// Load partial templates (for widgets)
+// Load action templates
+var Templates = (function($, ko) {
+  var module = function(options) {
+    var self = this;
+
+    var options = $.extend({
+      actions: {
+        mapreduce: 'static/templates/actions/mapreduce.html',
+        java: 'static/templates/actions/java.html',
+        streaming: 'static/templates/actions/streaming.html',
+        hive: 'static/templates/actions/hive.html',
+        pig: 'static/templates/actions/pig.html',
+        sqoop: 'static/templates/actions/sqoop.html',
+        fs: 'static/templates/actions/fs.html',
+        ssh: 'static/templates/actions/ssh.html',
+        shell: 'static/templates/actions/shell.html',
+        email: 'static/templates/actions/email.html',
+        distcp: 'static/templates/actions/distcp.html',
+      },
+      partials: {
+        archives: 'static/templates/widgets/filechooser.html',
+        files: 'static/templates/widgets/filechooser.html',
+        mkdirs: 'static/templates/widgets/filechooser.html',
+        deletes: 'static/templates/widgets/filechooser.html',
+        touchzs: 'static/templates/widgets/filechooser.html',
+        chmods: 'static/templates/widgets/filechooser.html',
+        moves: 'static/templates/widgets/filechooser.html',
+        job_properties: 'static/templates/widgets/properties.html',
+        prepares: 'static/templates/widgets/prepares.html',
+        arguments: 'static/templates/widgets/params.html',
+        args: 'static/templates/widgets/params.html',
+        params: 'static/templates/widgets/params.html',
+        arguments_envvars: 'static/templates/widgets/params.html',
+        params_arguments: 'static/templates/widgets/params.html',
+      }
+    }, options);
+
+    self.initialize(options);
+  };
+
+  $.extend(module.prototype, {
+    initialize: function(options) {
+      var self = this;
+
+      self.partials = {};
+      $.each(options.partials, function(widget_id, url) {
+        $.get(url, function(data) {
+          self.partials[widget_id] = data;
+        })
+      });
+
+      self.actions = {};
+      $.each(options.actions, function(action_id, url) {
+        $.get(url, function(data) {
+          self.actions[action_id] = data;
+        })
+      });
+    },
+    getActionTemplate: function(id, context) {
+      var self = this;
+      var el = $('#' + id);
+      if (el.length > 0 && el.html().length > 0) {
+        return el;
+      } else {
+        var html = Mustache.to_html(self.actions[id], context, self.partials);
+        if (el.length  == 0) el = $('<script/>');
+        el.attr('id', id);
+        el.attr('type', 'text/html');
+        el.html(html);
+        $(document.body).append(el);
+        return $('#' + id);
+      }
+    }
+  });
+
+  return module;
+})($, ko);
+var templates = new Templates();

+ 48 - 0
apps/jobsub/static/templates/actions/distcp.html

@@ -0,0 +1,48 @@
+<div id="pig" class="container-fluid">
+  <h1>{{ title }}</h1>
+  <form class="form-horizontal" id="workflowForm" action="#list-designs" method="POST">
+    <fieldset>
+      <div class="control-group" rel="popover" data-original-title="{{ name.name }}" data-content="{{ name.popover }}" data-bind="attr: {'class': ( errors.name().length > 0 ) ? 'control-group error' : 'control-group'}">
+        <label class="control-label">{{ name.name }}</label>
+        <div class="controls">
+          <input id="distcp-name" type="text" name="name" data-bind="value: name" />
+          <ul class="help-inline" data-bind="foreach: errors.name()">
+            <li class="error" data-bind="html: $data"></li>
+          </ul>
+        </div>
+      </div>
+
+      <div class="control-group" rel="popover" data-original-title="{{ description.name }}" data-content="{{ description.popover }}" data-bind="attr: {'class': ( errors.description().length > 0 ) ? 'control-group error' : 'control-group'}">
+        <label class="control-label">{{ description.name }}</label>
+        <div class="controls">
+          <input id="distcp-description" type="text" name="description" data-bind="value: description" />
+          <ul class="help-inline" data-bind="foreach: errors.description()">
+            <li class="error" data-bind="html: $data"></li>
+          </ul>
+        </div>
+      </div>
+
+      <hr/>
+
+      <div class="control-group">
+        <p class="alert alert-info">{{ alert }}</p>
+      </div>
+
+      {{#prepares}}
+        {{>prepares}}
+      {{/prepares}}
+
+      {{#params}}
+        {{>params}}
+      {{/params}}
+
+      {{#job_properties}}
+        {{>job_properties}}
+      {{/job_properties}}
+    </fieldset>
+    <div class="form-actions">
+      <button data-bind="click: {{ save.func }}" class="btn btn-primary">{{ save.name }}</button>
+      <a href="javascript:void(0);" data-bind="click: {{ cancel.func }}" class="btn">{{ cancel.name }}</a>
+    </div>
+  </form>
+</div>

+ 76 - 0
apps/jobsub/static/templates/actions/email.html

@@ -0,0 +1,76 @@
+<div id="java" class="container-fluid">
+  <h1>{{ title }}</h1>
+  <form class="form-horizontal" id="workflowForm" action="#list-designs" method="POST">
+    <fieldset>
+      <div class="control-group" rel="popover" data-original-title="{{ name.name }}" data-content="{{ name.popover }}" data-bind="attr: {'class': ( errors.name().length > 0 ) ? 'control-group error' : 'control-group'}">
+        <label class="control-label">{{ name.name }}</label>
+        <div class="controls">
+          <input id="email-name" type="text" name="name" data-bind="value: name" />
+          <ul class="help-inline" data-bind="foreach: errors.name()">
+            <li class="error" data-bind="html: $data"></li>
+          </ul>
+        </div>
+      </div>
+
+      <div class="control-group" rel="popover" data-original-title="{{ description.name }}" data-content="{{ description.popover }}" data-bind="attr: {'class': ( errors.description().length > 0 ) ? 'control-group error' : 'control-group'}">
+        <label class="control-label">{{ description.name }}</label>
+        <div class="controls">
+          <input id="email-description" type="text" name="description" data-bind="value: description" />
+          <ul class="help-inline" data-bind="foreach: errors.description()">
+            <li class="error" data-bind="html: $data"></li>
+          </ul>
+        </div>
+      </div>
+
+      <hr/>
+
+      <div class="control-group">
+        <p class="alert alert-info">{{ alert }}</p>
+      </div>
+
+      <div class="control-group" rel="popover" data-original-title="{{ to.name }}" data-content="{{ to.popover }}" data-bind="attr: {'class': ( errors.to().length > 0 ) ? 'control-group error' : 'control-group'}">
+        <label class="control-label">{{ to.name }}</label>
+        <div class="controls">
+          <input id="email-to" type="text" name="to" data-bind="value: to" />
+          <ul class="help-inline" data-bind="foreach: errors.to()">
+            <li class="error" data-bind="html: $data"></li>
+          </ul>
+        </div>
+      </div>
+
+      <div class="control-group" rel="popover" data-original-title="{{ cc.name }}" data-content="{{ cc.popover }}" data-bind="attr: {'class': ( errors.cc().length > 0 ) ? 'control-group error' : 'control-group'}">
+        <label class="control-label">{{ cc.name }}</label>
+        <div class="controls">
+          <input id="email-cc" type="text" name="cc" data-bind="value: cc" />
+          <ul class="help-inline" data-bind="foreach: errors.cc()">
+            <li class="error" data-bind="html: $data"></li>
+          </ul>
+        </div>
+      </div>
+
+      <div class="control-group" rel="popover" data-original-title="{{ subject.name }}" data-content="{{ subject.popover }}" data-bind="attr: {'class': ( errors.subject().length > 0 ) ? 'control-group error' : 'control-group'}">
+        <label class="control-label">{{ subject.name }}</label>
+        <div class="controls">
+          <input id="email-subject" type="text" name="subject" data-bind="value: subject" />
+          <ul class="help-inline" data-bind="foreach: errors.subject()">
+            <li class="error" data-bind="html: $data"></li>
+          </ul>
+        </div>
+      </div>
+
+      <div class="control-group" rel="popover" data-original-title="{{ body.name }}" data-content="{{ body.popover }}" data-bind="attr: {'class': ( errors.body().length > 0 ) ? 'control-group error' : 'control-group'}">
+        <label class="control-label">{{ body.name }}</label>
+        <div class="controls">
+          <textarea id="email-body" type="text" name="body" data-bind="value: body"></textarea>
+          <ul class="help-inline" data-bind="foreach: errors.body()">
+            <li class="error" data-bind="html: $data"></li>
+          </ul>
+        </div>
+      </div>
+    </fieldset>
+    <div class="form-actions">
+      <button data-bind="click: {{ save.func }}" class="btn btn-primary">{{ save.name }}</button>
+      <a href="javascript:void(0);" data-bind="click: {{ cancel.func }}" class="btn">{{ cancel.name }}</a>
+    </div>
+  </form>
+</div>

+ 56 - 0
apps/jobsub/static/templates/actions/fs.html

@@ -0,0 +1,56 @@
+<div id="mapreduce" class="container-fluid">
+  <h1>{{ title }}</h1>
+  <form class="form-horizontal" id="workflowForm" action="#list-designs" method="POST">
+    <fieldset>
+      <div class="control-group" rel="popover" data-original-title="{{ name.name }}" data-content="{{ name.popover }}" data-bind="attr: {'class': ( errors.name().length > 0 ) ? 'control-group error' : 'control-group'}">
+        <label class="control-label">{{ name.name }}</label>
+        <div class="controls">
+          <input id="fs-name" type="text" name="name" data-bind="value: name" />
+          <ul class="help-inline" data-bind="foreach: errors.name()">
+            <li class="error" data-bind="html: $data"></li>
+          </ul>
+        </div>
+      </div>
+
+      <div class="control-group" rel="popover" data-original-title="{{ description.name }}" data-content="{{ description.popover }}" data-bind="attr: {'class': ( errors.description().length > 0 ) ? 'control-group error' : 'control-group'}">
+        <label class="control-label">{{ description.name }}</label>
+        <div class="controls">
+          <input id="fs-description" type="text" name="description" data-bind="value: description" />
+          <ul class="help-inline" data-bind="foreach: errors.description()">
+            <li class="error" data-bind="html: $data"></li>
+          </ul>
+        </div>
+      </div>
+
+      <hr/>
+
+      <div class="control-group">
+        <p class="alert alert-info">{{ alert }}</p>
+      </div>
+
+      {{#deletes}}
+        {{>deletes}}
+      {{/deletes}}
+
+      {{#mkdirs}}
+        {{>mkdirs}}
+      {{/mkdirs}}
+
+      {{#moves}}
+        {{>moves}}
+      {{/moves}}
+
+      {{#chmods}}
+        {{>chmods}}
+      {{/chmods}}
+
+      {{#touchs}}
+        {{>touchs}}
+      {{/touchs}}
+    </fieldset>
+    <div class="form-actions">
+      <button data-bind="click: {{ save.func }}" class="btn btn-primary">{{ save.name }}</button>
+      <a href="javascript:void(0);" data-bind="click: {{ cancel.func }}" class="btn">{{ cancel.name }}</a>
+    </div>
+  </form>
+</div>

+ 66 - 0
apps/jobsub/static/templates/actions/hive.html

@@ -0,0 +1,66 @@
+<div id="hive" class="container-fluid">
+  <h1>{{ title }}</h1>
+  <form class="form-horizontal" id="workflowForm" action="#list-designs" method="POST">
+    <fieldset>
+      <div class="control-group" rel="popover" data-original-title="{{ name.name }}" data-content="{{ name.popover }}" data-bind="attr: {'class': ( errors.name().length > 0 ) ? 'control-group error' : 'control-group'}">
+        <label class="control-label">{{ name.name }}</label>
+        <div class="controls">
+          <input id="hive-name" type="text" name="name" data-bind="value: name" />
+          <ul class="help-inline" data-bind="foreach: errors.name()">
+            <li class="error" data-bind="html: $data"></li>
+          </ul>
+        </div>
+      </div>
+
+      <div class="control-group" rel="popover" data-original-title="{{ description.name }}" data-content="{{ description.popover }}" data-bind="attr: {'class': ( errors.description().length > 0 ) ? 'control-group error' : 'control-group'}">
+        <label class="control-label">{{ description.name }}</label>
+        <div class="controls">
+          <input id="hive-description" type="text" name="description" data-bind="value: description" />
+          <ul class="help-inline" data-bind="foreach: errors.description()">
+            <li class="error" data-bind="html: $data"></li>
+          </ul>
+        </div>
+      </div>
+
+      <hr/>
+
+      <div class="control-group">
+        <p class="alert alert-info">{{ alert }}</p>
+      </div>
+
+      <div class="control-group" rel="popover" data-original-title="{{ script_path.name }}" data-content="{{ script_path.popover }}" data-bind="attr: {'class': ( errors.script_path().length > 0 ) ? 'control-group error' : 'control-group'}">
+        <label class="control-label">{{ script_path.name }}</label>
+        <div class="controls">
+          <input id="hive-script-path" type="text" class="pathChooserKo" name="script-path" data-bind="fileChooser: $data, value: script_path" />
+          <ul class="help-inline" data-bind="foreach: errors.script_path()">
+            <li class="error" data-bind="html: $data"></li>
+          </ul>
+        </div>
+      </div>
+
+      {{#prepares}}
+        {{>prepares}}
+      {{/prepares}}
+
+      {{#params}}
+        {{>params}}
+      {{/params}}
+
+      {{#job_properties}}
+        {{>job_properties}}
+      {{/job_properties}}
+
+      {{#files}}
+        {{>files}}
+      {{/files}}
+
+      {{#archives}}
+        {{>archives}}
+      {{/archives}}
+    </fieldset>
+    <div class="form-actions">
+      <button data-bind="click: {{ save.func }}" class="btn btn-primary">{{ save.name }}</button>
+      <a href="hivescript:void(0);" data-bind="click: {{ cancel.func }}" class="btn">{{ cancel.name }}</a>
+    </div>
+  </form>
+</div>

+ 88 - 0
apps/jobsub/static/templates/actions/java.html

@@ -0,0 +1,88 @@
+<div id="java" class="container-fluid">
+  <h1>{{ title }}</h1>
+  <form class="form-horizontal" id="workflowForm" action="#list-designs" method="POST">
+    <fieldset>
+      <div class="control-group" rel="popover" data-original-title="{{ name.name }}" data-content="{{ name.popover }}" data-bind="attr: {'class': ( errors.name().length > 0 ) ? 'control-group error' : 'control-group'}">
+        <label class="control-label">{{ name.name }}</label>
+        <div class="controls">
+          <input id="java-name" type="text" name="name" data-bind="value: name" />
+          <ul class="help-inline" data-bind="foreach: errors.name()">
+            <li class="error" data-bind="html: $data"></li>
+          </ul>
+        </div>
+      </div>
+
+      <div class="control-group" rel="popover" data-original-title="{{ description.name }}" data-content="{{ description.popover }}" data-bind="attr: {'class': ( errors.description().length > 0 ) ? 'control-group error' : 'control-group'}">
+        <label class="control-label">{{ description.name }}</label>
+        <div class="controls">
+          <input id="java-description" type="text" name="description" data-bind="value: description" />
+          <ul class="help-inline" data-bind="foreach: errors.description()">
+            <li class="error" data-bind="html: $data"></li>
+          </ul>
+        </div>
+      </div>
+
+      <hr/>
+
+      <div class="control-group">
+        <p class="alert alert-info">{{ alert }}</p>
+      </div>
+
+      <div class="control-group" rel="popover" data-original-title="{{ jar_path.name }}" data-content="{{ jar_path.popover }}" data-bind="attr: {'class': ( errors.jar_path().length > 0 ) ? 'control-group error' : 'control-group'}">
+        <label class="control-label">{{ jar_path.name }}</label>
+        <div class="controls">
+          <input id="java-jar-path" type="text" name="jar-path" data-bind="value: jar_path" />
+          <ul class="help-inline" data-bind="foreach: errors.jar_path()">
+            <li class="error" data-bind="html: $data"></li>
+          </ul>
+        </div>
+      </div>
+
+      <div class="control-group" rel="popover" data-original-title="Main class" data-bind="attr: {'class': ( errors.main_class().length > 0 ) ? 'control-group error' : 'control-group'}">
+        <label class="control-label">Main class</label>
+        <div class="controls">
+          <input id="java-main-class" type="text" name="main-class" maxlength="256" data-bind="value: main_class" />
+          <ul class="help-inline" data-bind="foreach: errors.main_class()">
+            <li class="error" data-bind="html: $data"></li>
+          </ul>
+        </div>
+      </div>
+
+      <div class="control-group" rel="popover" data-original-title="Args" data-bind="attr: {'class': ( errors.args().length > 0 ) ? 'control-group error' : 'control-group'}">
+        <label class="control-label">Args</label>
+        <div class="controls">
+          <input id="java-args" type="text" name="args" maxlength="4096" data-bind="value: args" />
+          <ul class="help-inline" data-bind="foreach: errors.args()">
+            <li class="error" data-bind="html: $data"></li>
+          </ul>
+        </div>
+      </div>
+
+      <div class="control-group" rel="popover" data-original-title="Java opts" data-bind="attr: {'class': ( errors.java_opts().length > 0 ) ? 'control-group error' : 'control-group'}">
+        <label class="control-label">Java opts</label>
+        <div class="controls">
+          <input id="java-java-opts" type="text" name="java-opts" maxlength="256" data-bind="value: java_opts" />
+          <ul class="help-inline" data-bind="foreach: errors.java_opts()">
+            <li class="error" data-bind="html: $data"></li>
+          </ul>
+        </div>
+      </div>
+
+      {{#job_properties}}
+        {{>job_properties}}
+      {{/job_properties}}
+
+      {{#files}}
+        {{>files}}
+      {{/files}}
+
+      {{#archives}}
+        {{>archives}}
+      {{/archives}}
+    </fieldset>
+    <div class="form-actions">
+      <button data-bind="click: {{ save.func }}" class="btn btn-primary">{{ save.name }}</button>
+      <a href="javascript:void(0);" data-bind="click: {{ cancel.func }}" class="btn">{{ cancel.name }}</a>
+    </div>
+  </form>
+</div>

+ 58 - 0
apps/jobsub/static/templates/actions/mapreduce.html

@@ -0,0 +1,58 @@
+<div id="mapreduce" class="container-fluid">
+  <h1>{{ title }}</h1>
+  <form class="form-horizontal" id="workflowForm" action="#list-designs" method="POST">
+    <fieldset>
+      <div class="control-group" rel="popover" data-original-title="{{ name.name }}" data-content="{{ name.popover }}" data-bind="attr: {'class': ( errors.name().length > 0 ) ? 'control-group error' : 'control-group'}">
+        <label class="control-label">{{ name.name }}</label>
+        <div class="controls">
+          <input id="mapreduce-name" type="text" name="name" data-bind="value: name" />
+          <ul class="help-inline" data-bind="foreach: errors.name()">
+            <li class="error" data-bind="html: $data"></li>
+          </ul>
+        </div>
+      </div>
+
+      <div class="control-group" rel="popover" data-original-title="{{ description.name }}" data-content="{{ description.popover }}" data-bind="attr: {'class': ( errors.description().length > 0 ) ? 'control-group error' : 'control-group'}">
+        <label class="control-label">{{ description.name }}</label>
+        <div class="controls">
+          <input id="mapreduce-description" type="text" name="description" data-bind="value: description" />
+          <ul class="help-inline" data-bind="foreach: errors.description()">
+            <li class="error" data-bind="html: $data"></li>
+          </ul>
+        </div>
+      </div>
+
+      <hr/>
+
+      <div class="control-group">
+        <p class="alert alert-info">{{ alert }}</p>
+      </div>
+
+      <div class="control-group" rel="popover" data-original-title="{{ jar_path.name }}" data-content="{{ jar_path.popover }}" data-bind="attr: {'class': ( errors.jar_path().length > 0 ) ? 'control-group error' : 'control-group'}">
+        <label class="control-label">{{ jar_path.name }}</label>
+        <div class="controls">
+          <input id="mapreduce-jar-path" type="text" name="jar-path" data-bind="value: jar_path" />
+          <ul class="help-inline" data-bind="foreach: errors.jar_path()">
+            <li class="error" data-bind="html: $data"></li>
+          </ul>
+        </div>
+      </div>
+
+      {{#job_properties}}
+        {{>job_properties}}
+      {{/job_properties}}
+
+      {{#files}}
+        {{>files}}
+      {{/files}}
+
+      {{#archives}}
+        {{>archives}}
+      {{/archives}}
+    </fieldset>
+    <div class="form-actions">
+      <button data-bind="click: {{ save.func }}" class="btn btn-primary">{{ save.name }}</button>
+      <a href="javascript:void(0);" data-bind="click: {{ cancel.func }}" class="btn">{{ cancel.name }}</a>
+    </div>
+  </form>
+</div>

+ 66 - 0
apps/jobsub/static/templates/actions/pig.html

@@ -0,0 +1,66 @@
+<div id="pig" class="container-fluid">
+  <h1>{{ title }}</h1>
+  <form class="form-horizontal" id="workflowForm" action="#list-designs" method="POST">
+    <fieldset>
+      <div class="control-group" rel="popover" data-original-title="{{ name.name }}" data-content="{{ name.popover }}" data-bind="attr: {'class': ( errors.name().length > 0 ) ? 'control-group error' : 'control-group'}">
+        <label class="control-label">{{ name.name }}</label>
+        <div class="controls">
+          <input id="pig-name" type="text" name="name" data-bind="value: name" />
+          <ul class="help-inline" data-bind="foreach: errors.name()">
+            <li class="error" data-bind="html: $data"></li>
+          </ul>
+        </div>
+      </div>
+
+      <div class="control-group" rel="popover" data-original-title="{{ description.name }}" data-content="{{ description.popover }}" data-bind="attr: {'class': ( errors.description().length > 0 ) ? 'control-group error' : 'control-group'}">
+        <label class="control-label">{{ description.name }}</label>
+        <div class="controls">
+          <input id="pig-description" type="text" name="description" data-bind="value: description" />
+          <ul class="help-inline" data-bind="foreach: errors.description()">
+            <li class="error" data-bind="html: $data"></li>
+          </ul>
+        </div>
+      </div>
+
+      <hr/>
+
+      <div class="control-group">
+        <p class="alert alert-info">{{ alert }}</p>
+      </div>
+
+      <div class="control-group" rel="popover" data-original-title="{{ script_path.name }}" data-content="{{ script_path.popover }}" data-bind="attr: {'class': ( errors.script_path().length > 0 ) ? 'control-group error' : 'control-group'}">
+        <label class="control-label">{{ script_path.name }}</label>
+        <div class="controls">
+          <input id="pig-script-path" type="text" class="pathChooserKo" name="script-path" data-bind="fileChooser: $data, value: script_path" />
+          <ul class="help-inline" data-bind="foreach: errors.script_path()">
+            <li class="error" data-bind="html: $data"></li>
+          </ul>
+        </div>
+      </div>
+
+      {{#prepares}}
+        {{>prepares}}
+      {{/prepares}}
+
+      {{#params}}
+        {{>params}}
+      {{/params}}
+
+      {{#job_properties}}
+        {{>job_properties}}
+      {{/job_properties}}
+
+      {{#files}}
+        {{>files}}
+      {{/files}}
+
+      {{#archives}}
+        {{>archives}}
+      {{/archives}}
+    </fieldset>
+    <div class="form-actions">
+      <button data-bind="click: {{ save.func }}" class="btn btn-primary">{{ save.name }}</button>
+      <a href="javascript:void(0);" data-bind="click: {{ cancel.func }}" class="btn">{{ cancel.name }}</a>
+    </div>
+  </form>
+</div>

+ 71 - 0
apps/jobsub/static/templates/actions/shell.html

@@ -0,0 +1,71 @@
+<div id="shell" class="container-fluid">
+  <h1>{{ title }}</h1>
+  <form class="form-horizontal" id="workflowForm" action="#list-designs" method="POST">
+    <fieldset>
+      <div class="control-group" rel="popover" data-original-title="{{ name.name }}" data-content="{{ name.popover }}" data-bind="attr: {'class': ( errors.name().length > 0 ) ? 'control-group error' : 'control-group'}">
+        <label class="control-label">{{ name.name }}</label>
+        <div class="controls">
+          <input id="shell-name" type="text" name="name" data-bind="value: name" />
+          <ul class="help-inline" data-bind="foreach: errors.name()">
+            <li class="error" data-bind="html: $data"></li>
+          </ul>
+        </div>
+      </div>
+
+      <div class="control-group" rel="popover" data-original-title="{{ description.name }}" data-content="{{ description.popover }}" data-bind="attr: {'class': ( errors.description().length > 0 ) ? 'control-group error' : 'control-group'}">
+        <label class="control-label">{{ description.name }}</label>
+        <div class="controls">
+          <input id="shell-description" type="text" name="description" data-bind="value: description" />
+          <ul class="help-inline" data-bind="foreach: errors.description()">
+            <li class="error" data-bind="html: $data"></li>
+          </ul>
+        </div>
+      </div>
+
+      <hr/>
+
+      <div class="control-group">
+        <p class="alert alert-info">{{ alert }}</p>
+        <p class="alert alert-warn">{{ shell_alert }}</p>
+      </div>
+
+      <div class="control-group" rel="popover" data-original-title="{{ command.name }}" data-content="{{ command.popover }}" data-bind="attr: {'class': ( errors.command().length > 0 ) ? 'control-group error' : 'control-group'}">
+        <label class="control-label">{{ command.name }}</label>
+        <div class="controls">
+          <input id="shell-command" type="text" class="pathChooserKo" name="command" data-bind="fileChooser: $data, value: command" />
+          <ul class="help-inline" data-bind="foreach: errors.command()">
+            <li class="error" data-bind="html: $data"></li>
+          </ul>
+        </div>
+      </div>
+
+      {{#prepares}}
+        {{>prepares}}
+      {{/prepares}}
+
+      {{#params}}
+        {{>params}}
+      {{/params}}
+
+      {{#envvars}}
+        {{>envvars}}
+      {{/envvars}}
+
+      {{#job_properties}}
+        {{>job_properties}}
+      {{/job_properties}}
+
+      {{#files}}
+        {{>files}}
+      {{/files}}
+
+      {{#archives}}
+        {{>archives}}
+      {{/archives}}
+    </fieldset>
+    <div class="form-actions">
+      <button data-bind="click: {{ save.func }}" class="btn btn-primary">{{ save.name }}</button>
+      <a href="javascript:void(0);" data-bind="click: {{ cancel.func }}" class="btn">{{ cancel.name }}</a>
+    </div>
+  </form>
+</div>

+ 66 - 0
apps/jobsub/static/templates/actions/sqoop.html

@@ -0,0 +1,66 @@
+<div id="sqoop" class="container-fluid">
+  <h1>{{ title }}</h1>
+  <form class="form-horizontal" id="workflowForm" action="#list-designs" method="POST">
+    <fieldset>
+      <div class="control-group" rel="popover" data-original-title="{{ name.name }}" data-content="{{ name.popover }}" data-bind="attr: {'class': ( errors.name().length > 0 ) ? 'control-group error' : 'control-group'}">
+        <label class="control-label">{{ name.name }}</label>
+        <div class="controls">
+          <input id="sqoop-name" type="text" name="name" data-bind="value: name" />
+          <ul class="help-inline" data-bind="foreach: errors.name()">
+            <li class="error" data-bind="html: $data"></li>
+          </ul>
+        </div>
+      </div>
+
+      <div class="control-group" rel="popover" data-original-title="{{ description.name }}" data-content="{{ description.popover }}" data-bind="attr: {'class': ( errors.description().length > 0 ) ? 'control-group error' : 'control-group'}">
+        <label class="control-label">{{ description.name }}</label>
+        <div class="controls">
+          <input id="sqoop-description" type="text" name="description" data-bind="value: description" />
+          <ul class="help-inline" data-bind="foreach: errors.description()">
+            <li class="error" data-bind="html: $data"></li>
+          </ul>
+        </div>
+      </div>
+
+      <hr/>
+
+      <div class="control-group">
+        <p class="alert alert-info">{{ alert }}</p>
+      </div>
+
+      <div class="control-group" rel="popover" data-original-title="{{ script_path.name }}" data-content="{{ script_path.popover }}" data-bind="attr: {'class': ( errors.script_path().length > 0 ) ? 'control-group error' : 'control-group'}">
+        <label class="control-label">{{ script_path.name }}</label>
+        <div class="controls">
+          <textarea id="sqoop-script-path" type="text" name="script-path" data-bind="value: script_path"></textarea>
+          <ul class="help-inline" data-bind="foreach: errors.script_path()">
+            <li class="error" data-bind="html: $data"></li>
+          </ul>
+        </div>
+      </div>
+
+      {{#prepares}}
+        {{>prepares}}
+      {{/prepares}}
+
+      {{#params}}
+        {{>params}}
+      {{/params}}
+
+      {{#job_properties}}
+        {{>job_properties}}
+      {{/job_properties}}
+
+      {{#files}}
+        {{>files}}
+      {{/files}}
+
+      {{#archives}}
+        {{>archives}}
+      {{/archives}}
+    </fieldset>
+    <div class="form-actions">
+      <button data-bind="click: {{ save.func }}" class="btn btn-primary">{{ save.name }}</button>
+      <a href="javascript:void(0);" data-bind="click: {{ cancel.func }}" class="btn">{{ cancel.name }}</a>
+    </div>
+  </form>
+</div>

+ 71 - 0
apps/jobsub/static/templates/actions/ssh.html

@@ -0,0 +1,71 @@
+<div id="mapreduce" class="container-fluid">
+  <h1>{{ title }}</h1>
+  <form class="form-horizontal" id="workflowForm" action="#list-designs" method="POST">
+    <fieldset>
+      <div class="control-group" rel="popover" data-original-title="{{ name.name }}" data-content="{{ name.popover }}" data-bind="attr: {'class': ( errors.name().length > 0 ) ? 'control-group error' : 'control-group'}">
+        <label class="control-label">{{ name.name }}</label>
+        <div class="controls">
+          <input id="ssh-name" type="text" name="name" data-bind="value: name" />
+          <ul class="help-inline" data-bind="foreach: errors.name()">
+            <li class="error" data-bind="html: $data"></li>
+          </ul>
+        </div>
+      </div>
+
+      <div class="control-group" rel="popover" data-original-title="{{ description.name }}" data-content="{{ description.popover }}" data-bind="attr: {'class': ( errors.description().length > 0 ) ? 'control-group error' : 'control-group'}">
+        <label class="control-label">{{ description.name }}</label>
+        <div class="controls">
+          <input id="ssh-description" type="text" name="description" data-bind="value: description" />
+          <ul class="help-inline" data-bind="foreach: errors.description()">
+            <li class="error" data-bind="html: $data"></li>
+          </ul>
+        </div>
+      </div>
+
+      <hr/>
+
+      <div class="control-group">
+        <p class="alert alert-info">{{ alert }}</p>
+        <p class="alert alert-warn">{{ ssh_alert }}</p>
+      </div>
+
+      <div class="control-group" rel="popover" data-original-title="{{ user.name }}" data-content="{{ user.popover }}" data-bind="attr: {'class': ( errors.user().length > 0 ) ? 'control-group error' : 'control-group'}">
+        <label class="control-label">{{ user.name }}</label>
+        <div class="controls">
+          <input id="ssh-user" type="text" name="user" data-bind="value: user" />
+          <ul class="help-inline" data-bind="foreach: errors.user()">
+            <li class="error" data-bind="html: $data"></li>
+          </ul>
+        </div>
+      </div>
+
+      <div class="control-group" rel="popover" data-original-title="{{ host.name }}" data-content="{{ host.popover }}" data-bind="attr: {'class': ( errors.host().length > 0 ) ? 'control-group error' : 'control-group'}">
+        <label class="control-label">{{ host.name }}</label>
+        <div class="controls">
+          <input id="ssh-host" type="text" name="host" data-bind="value: host" />
+          <ul class="help-inline" data-bind="foreach: errors.host()">
+            <li class="error" data-bind="html: $data"></li>
+          </ul>
+        </div>
+      </div>
+
+      <div class="control-group" rel="popover" data-original-title="{{ command.name }}" data-content="{{ command.popover }}" data-bind="attr: {'class': ( errors.command().length > 0 ) ? 'control-group error' : 'control-group'}">
+        <label class="control-label">{{ command.name }}</label>
+        <div class="controls">
+          <input id="ssh-command" type="text" name="command" data-bind="value: command" />
+          <ul class="help-inline" data-bind="foreach: errors.command()">
+            <li class="error" data-bind="html: $data"></li>
+          </ul>
+        </div>
+      </div>
+
+      {{#args}}
+        {{>args}}
+      {{/args}}
+    </fieldset>
+    <div class="form-actions">
+      <button data-bind="click: {{ save.func }}" class="btn btn-primary">{{ save.name }}</button>
+      <a href="javascript:void(0);" data-bind="click: {{ cancel.func }}" class="btn">{{ cancel.name }}</a>
+    </div>
+  </form>
+</div>

+ 68 - 0
apps/jobsub/static/templates/actions/streaming.html

@@ -0,0 +1,68 @@
+<div id="mapreduce" class="container-fluid">
+  <h1>{{ title }}</h1>
+  <form class="form-horizontal" id="workflowForm" action="#list-designs" method="POST">
+    <fieldset>
+      <div class="control-group" rel="popover" data-original-title="{{ name.name }}" data-content="{{ name.popover }}" data-bind="attr: {'class': ( errors.name().length > 0 ) ? 'control-group error' : 'control-group'}">
+        <label class="control-label">{{ name.name }}</label>
+        <div class="controls">
+          <input id="streaming-name" type="text" name="name" data-bind="value: name" />
+          <ul class="help-inline" data-bind="foreach: errors.name()">
+            <li class="error" data-bind="html: $data"></li>
+          </ul>
+        </div>
+      </div>
+
+      <div class="control-group" rel="popover" data-original-title="{{ description.name }}" data-content="{{ description.popover }}" data-bind="attr: {'class': ( errors.description().length > 0 ) ? 'control-group error' : 'control-group'}">
+        <label class="control-label">{{ description.name }}</label>
+        <div class="controls">
+          <input id="streaming-description" type="text" name="description" data-bind="value: description" />
+          <ul class="help-inline" data-bind="foreach: errors.description()">
+            <li class="error" data-bind="html: $data"></li>
+          </ul>
+        </div>
+      </div>
+
+      <hr/>
+
+      <div class="control-group">
+        <p class="alert alert-info">{{ alert }}</p>
+      </div>
+
+      <div class="control-group" rel="popover" data-original-title="{{ mapper.name }}" data-content="{{ mapper.popover }}" data-bind="attr: {'class': ( errors.mapper().length > 0 ) ? 'control-group error' : 'control-group'}">
+        <label class="control-label">{{ mapper.name }}</label>
+        <div class="controls">
+          <input id="streaming-mapper" type="text" name="mapper" data-bind="value: mapper" />
+          <ul class="help-inline" data-bind="foreach: errors.mapper()">
+            <li class="error" data-bind="html: $data"></li>
+          </ul>
+        </div>
+      </div>
+
+      <div class="control-group" rel="popover" data-original-title="{{ reducer.name }}" data-content="{{ reducer.popover }}" data-bind="attr: {'class': ( errors.reducer().length > 0 ) ? 'control-group error' : 'control-group'}">
+        <label class="control-label">{{ reducer.name }}</label>
+        <div class="controls">
+          <input id="streaming-reducer" type="text" name="reducer" data-bind="value: reducer" />
+          <ul class="help-inline" data-bind="foreach: errors.reducer()">
+            <li class="error" data-bind="html: $data"></li>
+          </ul>
+        </div>
+      </div>
+
+      {{#job_properties}}
+        {{>job_properties}}
+      {{/job_properties}}
+
+      {{#files}}
+        {{>files}}
+      {{/files}}
+
+      {{#archives}}
+        {{>archives}}
+      {{/archives}}
+    </fieldset>
+    <div class="form-actions">
+      <button data-bind="click: {{ save.func }}" class="btn btn-primary">{{ save.name }}</button>
+      <a href="javascript:void(0);" data-bind="click: {{ cancel.func }}" class="btn">{{ cancel.name }}</a>
+    </div>
+  </form>
+</div>

+ 33 - 0
apps/jobsub/static/templates/designs.html

@@ -0,0 +1,33 @@
+<table class="table table-striped table-condensed datatables">
+  <thead>
+    <tr>
+      <th width="1%"><div class="hueCheckbox selectAll" data-selectables="savedCheck"></div></th>
+      <th>{{ name }}</th>
+      <th>{{ description }}</th>
+      <th>{{ owner }}</th>
+      <th>{{ type }}</th>
+      <th>{{ last_modified }}</th>
+    </tr>
+  </thead>
+  <tbody data-bind="foreach: designs">
+    <tr data-bind="with: design">
+      <td data-row-selector-exclude="true">
+        <div class="hueCheckbox savedCheck" data-row-selector-exclude="true"></div>
+      </td>
+      <td><a href="javascript:void(0);" data-row-selector="true" data-bind="text: name, click: function(data, event) {$root.select.call($root, $index()); $root.showDesign.call($root, data, event);}"></a></td>
+      <td data-bind="text: description"></td>
+      <td data-bind="text: owner"></td>
+      <td data-bind="text: type"></td>
+      <td data-sort-value="${time.mktime(design.mtime.timetuple())}" data-bind="text: last_modified"></td>
+    </tr>
+  </tbody>
+</table>
+<div class="pagination">
+    <ul class="pull-right">
+        <li class="prev"><a title="{{ first }}" ${toppage(page)}>&larr; {{ first }}</a></li>
+        <li><a title="{{ previous }}" ${prevpage(page)}>{{ previous }}</a></li>
+        <li><a title="{{ next }}" ${nextpage(page)}>{{ next }}</a></li>
+        <li class="next"><a title="{{ last }}" ${bottompage(page)}>{{ last }} &rarr;</a></li>
+    </ul>
+    <!-- <p>${_('Showing %(start)s to %(end)s of %(count)s items, page %(page)s of %(pages)s') % dict(start=page.start_index(),end=page.end_index(),count=page.total_count(),page=page.number,pages=page.num_pages())}</p> -->
+</div>

+ 14 - 0
apps/jobsub/static/templates/widgets/filechooser.html

@@ -0,0 +1,14 @@
+<div class="control-group" data-bind="attr: {'class': {{ ko.error_class }} }">
+	<label class="control-label">{{ title }}</label>
+	<div class="controls">
+	  <table class="table-condensed designTable" data-bind="visible: {{ ko.condition }}">
+	    <tbody data-bind="foreach: {{ ko.items }}">
+	      <tr>
+	        <td><input type="text" class="input span5 required pathChooserKo" data-bind="fileChooser: $data, value: name, uniqueName: false" /></td>
+	        <td><a class="btn" href="javascript:void(0);" data-bind="click: {{ delete.func }}">{{ delete.name }}</a></td>
+	      </tr>
+	    </tbody>
+	  </table>
+	  <button class="btn" data-bind="click: {{ add.func }}">{{ add.name }}</button>
+	</div>
+</div>

+ 24 - 0
apps/jobsub/static/templates/widgets/params.html

@@ -0,0 +1,24 @@
+<div class="control-group" data-bind="attr: {'class': {{ ko.error_class }} }">
+  <label class="control-label">{{ title }}</label>
+  <div class="controls">
+    <table class="table-condensed designTable" data-bind="if: {{ ko.condition }}">
+      <thead>
+        <tr>
+          <th>{{ name }}</th>
+          <th>{{ value }}</th>
+          <th />
+        </tr>
+      </thead>
+      <tbody data-bind="foreach: {{ ko.items }}">
+        <tr>
+          <td data-bind="text: type"></td>
+          <td><input type="text" class="span4 required pathChooserKo" data-bind="fileChooser: $data, value: value, uniqueName: false" /></td>
+          <td><a class="btn btn-small" href="javascript:void(0);" data-bind="click: {{ delete.func }}">{{ delete.name }}</a></td>
+        </tr>
+      </tbody>
+    </table>
+    {{#add}}
+      <button class="btn" data-bind="click: {{ func }}">{{ name }}</button>
+    {{/add}}
+  </div>
+</div>

+ 23 - 0
apps/jobsub/static/templates/widgets/prepares.html

@@ -0,0 +1,23 @@
+<div class="control-group" data-bind="attr: {'class': {{ ko.error_class }} }">
+  <label class="control-label">{{ title }}</label>
+  <div class="controls">
+    <table class="table-condensed designTable" data-bind="if: {{ ko.condition }}">
+      <thead>
+        <tr>
+          <th>{{ name }}</th>
+          <th>{{ value }}</th>
+          <th />
+        </tr>
+      </thead>
+      <tbody data-bind="foreach: {{ ko.items }}">
+        <tr>
+          <td data-bind="text: type"></td>
+          <td><input type="text" class="span4 required pathChooserKo" data-bind="fileChooser: $data, value: value, uniqueName: false" /></td>
+          <td><a class="btn btn-small" href="javascript:void(0);" data-bind="click: {{ delete.func }}">{{ delete.name }}</a></td>
+        </tr>
+      </tbody>
+    </table>
+    <button class="btn" data-bind="click: {{ add.delete.func }}">{{ add.delete.name }}</button>
+    <button class="btn" data-bind="click: {{ add.mkdir.func }}">{{ add.mkdir.name }}</button>
+  </div>
+</div>

+ 22 - 0
apps/jobsub/static/templates/widgets/properties.html

@@ -0,0 +1,22 @@
+<div class="control-group" data-bind="attr: {'class': {{ ko.error_class }} }">
+  <label class="control-label">{{ title }}</label>
+  <div class="controls">
+    <table class="table-condensed designTable" data-bind="if: {{ ko.condition }}">
+      <thead>
+        <tr>
+          <th>{{ name }}</th>
+          <th>{{ value }}</th>
+          <th />
+        </tr>
+      </thead>
+      <tbody data-bind="foreach: {{ ko.items }}">
+        <tr>
+          <td><input type="text" class="span3 required propKey" data-bind="value: name, uniqueName: false" /></td>
+          <td><input type="text" class="span4 required pathChooserKo" data-bind="fileChooser: $data, value: value, uniqueName: false" /></td>
+          <td><a class="btn btn-small" href="javascript:void(0);" data-bind="click: {{ delete.func }}">{{ delete.name }}</a></td>
+        </tr>
+      </tbody>
+    </table>
+    <button class="btn" data-bind="click: {{ add.func }}">{{ add.name }}</button>
+  </div>
+</div>

+ 1 - 0
apps/oozie/src/oozie/import_jobsub.py

@@ -5,6 +5,7 @@ from jobsub.models import OozieDesign, OozieMapreduceAction, OozieStreamingActio
 
 from oozie.models import Mapreduce, Java, Streaming
 
+
 def convert_jobsub_design(jobsub_design):
   """Creates an oozie action from a jobsub design"""
   action = jobsub_design.get_root_action()

+ 304 - 0
apps/oozie/src/oozie/migrations/0018_auto__add_field_workflow_managed.py

@@ -0,0 +1,304 @@
+# 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 field 'Workflow.managed'
+        db.add_column('oozie_workflow', 'managed', self.gf('django.db.models.fields.BooleanField')(default=True, blank=True), keep_default=False)
+    
+    
+    def backwards(self, orm):
+        
+        # Deleting field 'Workflow.managed'
+        db.delete_column('oozie_workflow', 'managed')
+    
+    
+    models = {
+        'auth.group': {
+            'Meta': {'object_name': 'Group'},
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'name': ('django.db.models.fields.CharField', [], {'max_length': '80', 'unique': 'True'}),
+            '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', [], {'max_length': '30', 'unique': 'True'})
+        },
+        '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.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, 15, 11, 54, 39, 423057)'}),
+            '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_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, 12, 11, 54, 39, 423001)'}),
+            '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, 12, 11, 54, 39, 423990)'}),
+            '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', [], {'related_name': "'end_workflow'", 'blank': 'True', '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'}),
+            'managed': ('django.db.models.fields.BooleanField', [], {'default': 'True', 'blank': 'True'}),
+            'start': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'start_workflow'", 'blank': 'True', 'null': 'True', 'to': "orm['oozie.Start']"})
+        }
+    }
+    
+    complete_apps = ['oozie']

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

@@ -249,6 +249,7 @@ class Workflow(Job):
   job_properties = models.TextField(default='[]', verbose_name=_t('Hadoop job properties'),
                                     help_text=_t('Job configuration properties used by all the actions of the workflow '
                                                  '(e.g. mapred.job.queue.name=production)'))
+  managed = models.BooleanField(default=True)
 
   objects = WorkflowManager()
 
@@ -613,6 +614,11 @@ class Node(models.Model):
   def is_visible(self):
     return True
 
+  def add_node(self, child):
+    raise NotImplementedError(_("%(node_type)s has not implemented the 'add_node' method.") % {
+      'node_type': self.node_type
+    })
+
 
 class Action(Node):
   """
@@ -624,6 +630,13 @@ class Action(Node):
     # Cloning does not work anymore if not abstract
     abstract = True
 
+  def add_node(self, child):
+    Link.objects.filter(parent=self, name='ok').delete()
+    Link.objects.create(parent=self, child=child, name='ok')
+    if not Link.objects.filter(parent=self, name='error').exists():
+      Link.objects.create(parent=self, child=Kill.objects.get(name='kill', workflow=self.workflow), name='error')
+
+
 # The fields with '[]' as default value are JSON dictionaries
 # When adding a new action, also update
 #  - Action.types below
@@ -1037,16 +1050,26 @@ class ControlFlow(Node):
 class Start(ControlFlow):
   node_type = 'start'
 
+  def add_node(self, child):
+    Link.objects.filter(parent=self).delete()
+    link = Link.objects.create(parent=self, child=child, name='to')
+
 
 class End(ControlFlow):
   node_type = 'end'
 
+  def add_node(self, child):
+    raise RuntimeError(_("End should not have any children."))
+
 
 class Kill(ControlFlow):
   node_type = 'kill'
 
   message = models.CharField(max_length=256, blank=False, default='Action failed, error message[${wf:errorMessage(wf:lastErrorNode())}]')
 
+  def add_node(self, child):
+    raise RuntimeError(_("Kill should not have any children."))
+
 
 class Fork(ControlFlow):
   """

+ 1 - 1
apps/oozie/src/oozie/templates/dashboard/list_oozie_bundle.mako

@@ -263,7 +263,7 @@ ${ layout.menubar(section='dashboard') }
   </div>
 </div>
 
-<script src="/oozie/static/js/utils.js" type="text/javascript" charset="utf-8"></script>
+<script src="/oozie/static/js/bundles.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">

+ 1 - 1
apps/oozie/src/oozie/templates/dashboard/list_oozie_bundles.mako

@@ -105,7 +105,7 @@ ${layout.menubar(section='dashboard')}
   </div>
 </div>
 
-<script src="/oozie/static/js/utils.js" type="text/javascript" charset="utf-8"></script>
+<script src="/oozie/static/js/bundles.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">

+ 1 - 1
apps/oozie/src/oozie/templates/dashboard/list_oozie_coordinator.mako

@@ -282,7 +282,7 @@ ${ layout.menubar(section='dashboard') }
   </div>
 </div>
 
-<script src="/oozie/static/js/utils.js" type="text/javascript" charset="utf-8"></script>
+<script src="/oozie/static/js/bundles.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">

+ 1 - 1
apps/oozie/src/oozie/templates/dashboard/list_oozie_coordinators.mako

@@ -105,7 +105,7 @@ ${layout.menubar(section='dashboard')}
   </div>
 </div>
 
-<script src="/oozie/static/js/utils.js" type="text/javascript" charset="utf-8"></script>
+<script src="/oozie/static/js/bundles.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">

+ 1 - 1
apps/oozie/src/oozie/templates/dashboard/list_oozie_workflow.mako

@@ -292,7 +292,7 @@ ${ layout.menubar(section='dashboard') }
   </div>
 </div>
 
-<script src="/oozie/static/js/utils.js" type="text/javascript" charset="utf-8"></script>
+<script src="/oozie/static/js/bundles.utils.js" type="text/javascript" charset="utf-8"></script>
 <link rel="stylesheet" href="/oozie/static/css/workflow.css">
 <script src="/static/ext/js/knockout-2.1.0.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>

+ 1 - 1
apps/oozie/src/oozie/templates/dashboard/list_oozie_workflows.mako

@@ -107,7 +107,7 @@ ${ layout.menubar(section='dashboard') }
   </div>
 </div>
 
-<script src="/oozie/static/js/utils.js" type="text/javascript" charset="utf-8"></script>
+<script src="/oozie/static/js/bundles.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">

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

@@ -238,8 +238,8 @@
             <%
             job_properties_field(action_form['job_properties'], {
               'name': 'job_properties',
-              'add': 'addProp',
-              'remove': '$parent.removeProp'
+              'add': 'addProperty',
+              'remove': '$parent.removeProperty'
             })
             %>
           % endif

+ 10 - 3
apps/oozie/src/oozie/templates/editor/edit_workflow.mako

@@ -282,6 +282,16 @@ ${ layout.menubar(section='workflows') }
 <script src="/static/ext/js/jquery/plugins/jquery-ui-draggable-droppable-sortable-1.8.23.min.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>
 
+<link rel="stylesheet" href="/oozie/static/css/workflow.css">
+<script type="text/javascript" src="/oozie/static/js/workflow.utils.js"></script>
+<script type="text/javascript" src="/oozie/static/js/workflow.registry.js"></script>
+<script type="text/javascript" src="/oozie/static/js/workflow.modal.js"></script>
+<script type="text/javascript" src="/oozie/static/js/workflow.models.js"></script>
+<script type="text/javascript" src="/oozie/static/js/workflow.idgen.js"></script>
+<script type="text/javascript" src="/oozie/static/js/workflow.node-fields.js"></script>
+<script type="text/javascript" src="/oozie/static/js/workflow.node.js"></script>
+<script type="text/javascript" src="/oozie/static/js/workflow.js"></script>
+
 
 % for form_info in action_forms:
   ${ actions.action_form(action_form=form_info[1], node_type=form_info[0], template=True) }
@@ -398,9 +408,6 @@ ${ controls.decision_form(node_form, link_form, default_link_form, 'decision', T
   <div class="node-link">&nbsp;</div>
 </script>
 
-<link rel="stylesheet" href="/oozie/static/css/workflow.css">
-<script type="text/javascript" src="/oozie/static/js/workflow.js"></script>
-
 <script type="text/javascript">
 /**
  * Component Initialization

+ 5 - 1
apps/oozie/src/oozie/tests.py

@@ -268,7 +268,10 @@ class OozieBase(OozieServerProvider):
     self.c.post(reverse('oozie:setup_app'))
     self.cluster.fs.do_as_user('test', self.cluster.fs.create_home_dir, '/user/test')
     self.cluster.fs.do_as_superuser(self.cluster.fs.chmod, '/user/test', 0777, True)
-    hue = User.objects.create_user('hue', 'hue' + '@localhost', 'hue')
+    try:
+      hue = User.objects.get(username='hue')
+    except User.DoesNotExist:
+      hue = User.objects.create_user('hue', 'hue' + '@localhost', 'hue')
     Workflow.objects.update(owner=hue)
 
     _INITIALIZED = True
@@ -2517,6 +2520,7 @@ def create_workflow(client, workflow_dict=WORKFLOW_DICT):
 
   wf = Workflow.objects.get(name=name)
   assert_not_equal('', wf.deployment_dir)
+  assert_true(wf.managed)
 
   return wf
 

+ 30 - 0
apps/oozie/src/oozie/utils.py

@@ -15,6 +15,10 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 
+try:
+  import json
+except ImportError:
+  import simplejson as json
 import logging
 import re
 
@@ -24,6 +28,25 @@ from jobsub.parameterization import find_variables
 
 LOG = logging.getLogger(__name__)
 
+JSON_FIELDS = ('parameters', 'job_properties', 'files', 'archives', 'prepares', 'params',
+               'deletes', 'mkdirs', 'moves', 'chmods', 'touchzs')
+NUMBER_FIELDS = ('sub_workflow',)
+
+def format_field_value(field, value):
+  if field in JSON_FIELDS:
+    if not isinstance(value, basestring):
+      return json.dumps(value)
+  if field in NUMBER_FIELDS:
+    if not isinstance(value, int):
+      return int(value)
+  return value
+
+
+def format_dict_field_values(dictionary):
+  for key in dictionary:
+    dictionary[key] = format_field_value(key, dictionary[key])
+  return dictionary
+
 def model_to_dict(model):
   from django.db import models
   from datetime import datetime
@@ -42,6 +65,13 @@ def model_to_dict(model):
   return dictionary
 
 
+def sanitize_node_dict(node_dict):
+  for field in ['node_ptr', 'workflow']:
+    if field in node_dict:
+      del node_dict[field]
+  return node_dict
+
+
 def workflow_to_dict(workflow):
   workflow_dict = model_to_dict(workflow)
   node_list = [node.get_full_node() for node in workflow.node_list]

+ 2 - 22
apps/oozie/src/oozie/views/api.py

@@ -34,32 +34,12 @@ from oozie.models import Workflow, Node, Start, End, Kill, Mapreduce, Java, Stre
                          Link, Decision, Fork, DecisionEnd, Join,\
                          NODE_TYPES, ACTION_TYPES, _STD_PROPERTIES_JSON
 from oozie.decorators import check_job_access_permission, check_job_edition_permission
-from oozie.utils import model_to_dict
+from oozie.utils import model_to_dict, format_dict_field_values, format_field_value
 
 
 LOG = logging.getLogger(__name__)
 
 
-JSON_FIELDS = ('parameters', 'job_properties', 'files', 'archives', 'prepares', 'params',
-               'deletes', 'mkdirs', 'moves', 'chmods', 'touchzs')
-NUMBER_FIELDS = ('sub_workflow',)
-
-def format_field_value(field, value):
-  if field in JSON_FIELDS:
-    if not isinstance(value, basestring):
-      return json.dumps(value)
-  if field in NUMBER_FIELDS:
-    if not isinstance(value, int):
-      return int(value)
-  return value
-
-
-def format_dict_field_values(dictionary):
-  for key in dictionary:
-    dictionary[key] = format_field_value(key, dictionary[key])
-  return dictionary
-
-
 def get_or_create_node(workflow, node_data):
   node = None
   id = str(node_data['id'])
@@ -114,7 +94,7 @@ def _validate_node_links_json(node_type, node_links, errors):
       return False
   elif node_type in (Fork.node_type, Decision.node_type):
     if len(node_links) < 2:
-      errors['links'] = _("Join and Decision should have at least two children: 'related' to their respective ends, 'start' to any node.")
+      errors['links'] = _("Fork and Decision should have at least two children: 'related' to their respective ends, 'start' to any node.")
       return False
   else:
     if len(node_links) != 2:

+ 1 - 2
apps/oozie/src/oozie/views/dashboard.py

@@ -35,10 +35,9 @@ from desktop.lib.rest.http_client import RestException
 from desktop.log.access import access_warn
 from liboozie.oozie_api import get_oozie
 from liboozie.submittion import Submission
-from oozie.forms import RerunForm, ParameterForm, RerunCoordForm
-
 
 from oozie.conf import OOZIE_JOBS_COUNT
+from oozie.forms import RerunForm, ParameterForm, RerunCoordForm
 from oozie.models import History, Job, Workflow
 from oozie.settings import DJANGO_APPS
 

+ 2 - 1
apps/oozie/src/oozie/views/editor.py

@@ -55,7 +55,7 @@ LOG = logging.getLogger(__name__)
 
 def list_workflows(request):
   show_setup_app = True
-  data = Workflow.objects
+  data = Workflow.objects.filter(managed=True)
 
   if not SHARE_JOBS.get() and not request.user.is_superuser:
     data = data.filter(owner=request.user)
@@ -113,6 +113,7 @@ def create_workflow(request):
 
     if workflow_form.is_valid():
       wf = workflow_form.save()
+      wf.managed = True
       Workflow.objects.initialize(wf, request.fs)
       return redirect(reverse('oozie:edit_workflow', kwargs={'workflow': workflow.id}))
     else:

+ 0 - 0
apps/oozie/static/js/utils.js → apps/oozie/static/js/bundles.utils.js


+ 39 - 0
apps/oozie/static/js/workflow.idgen.js

@@ -0,0 +1,39 @@
+/**
+ * ID Generator
+ * Generate a new ID starting from 1.
+ * - Accepts a prefix that will be prepended like so: <prefix>:<id number>
+ */
+var IdGeneratorModule = function($) {
+  return function(options) {
+    var self = this;
+    $.extend(self, options);
+
+    self.counter = 1;
+
+    self.nextId = function() {
+      return ((self.prefix) ? self.prefix + ':' : '') + self.counter++;
+    };
+  };
+};
+var IdGenerator = IdGeneratorModule($);
+
+var IdGeneratorTable = {
+  mapreduce: new IdGenerator({prefix: 'mapreduce'}),
+  streaming: new IdGenerator({prefix: 'streaming'}),
+  java: new IdGenerator({prefix: 'java'}),
+  pig: new IdGenerator({prefix: 'pig'}),
+  hive: new IdGenerator({prefix: 'hive'}),
+  sqoop: new IdGenerator({prefix: 'sqoop'}),
+  shell: new IdGenerator({prefix: 'shell'}),
+  ssh: new IdGenerator({prefix: 'ssh'}),
+  distcp: new IdGenerator({prefix: 'distcp'}),
+  fs: new IdGenerator({prefix: 'fs'}),
+  email: new IdGenerator({prefix: 'email'}),
+  subworkflow: new IdGenerator({prefix: 'subworkflow'}),
+  generic: new IdGenerator({prefix: 'generic'}),
+  fork: new IdGenerator({prefix: 'fork'}),
+  decision: new IdGenerator({prefix: 'decision'}),
+  join: new IdGenerator({prefix: 'join'}),
+  decisionend: new IdGenerator({prefix: 'decisionend'}),
+  kill: new IdGenerator({prefix: 'kill'})
+};

Plik diff jest za duży
+ 2 - 1388
apps/oozie/static/js/workflow.js


+ 131 - 0
apps/oozie/static/js/workflow.modal.js

@@ -0,0 +1,131 @@
+// 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.
+
+/**
+ * Modal Control
+ * Displays a single node in a modal window of your choosing.
+ * Make sure to update the 'context' member and 'template' members.
+ * IMPORTANT: Use 'setTemplate', then 'show'.
+ */
+var ModalModule = function($, ko) {
+  var module = function(modal, template) {
+    var self = this;
+
+    self.el = self.modal = $(modal);
+    self.context = ko.observable();
+    self.template = ko.observable(template || '');
+    self.bound = false;
+
+    // exit with escape key.
+    $(window).on('keyup', function(e) {
+      if (e.keyCode == 27) {
+        $('.modal-backdrop').click();
+      }
+    });
+  };
+
+  module.prototype.show = function(context) {
+    var self = this;
+
+    if (context) {
+      self.context(context);
+    }
+
+    ko.applyBindings(self, self.modal[0]);
+    self.modal.modal('show');
+  };
+
+  module.prototype.hide = function() {
+    var self = this;
+
+    self.el.modal('hide');
+  };
+
+  module.prototype.setTemplate = function(template) {
+    var self = this;
+
+    ko.cleanNode(self.modal[0]);
+    self.template( template );
+  };
+
+  module.prototype.recenter = function(offset_x, offset_y) {
+    var self = this;
+
+    var MARGIN = 10; // pixels around the modal
+
+    var modalContentHeight = (($(window).height() - MARGIN*2) -
+        (self.modal.find(".modal-header").outerHeight() + self.modal.find(".modal-header").outerHeight())) - 20;
+
+    self.modal.css("width", ($(window).width() - MARGIN*2)+"px");
+    self.modal.find(".modal-content").css("max-height", modalContentHeight+"px").css("height", modalContentHeight+"px");
+
+    var top = ( ($(window).height() - self.modal.outerHeight(false)) / 2 );
+    var left = ( ($(window).width() - self.modal.outerWidth(false)) / 2 );
+    if (top < 0) {
+      top = 0;
+    }
+    if (left < 0) {
+      left = 0;
+    }
+    top += offset_y || 0;
+    left += offset_x || 0;
+    self.modal.css({top: top +'px', left:  left+'px'});
+  };
+
+  module.prototype.addDecorations = function () {
+    $(".popover").remove();
+
+    $("input[name='job_xml']:not(.pathChooser)").addClass("pathChooser").after(getFileBrowseButton($("input[name='job_xml']:not(.pathChooser)")));
+    $("input[name='jar_path']").addClass("pathChooser").after(getFileBrowseButton($("input[name='jar_path']")));
+    $("input[name='script_path']").addClass("pathChooser").after(getFileBrowseButton($("input[name='script_path']")));
+    $("input[name='command']").addClass("pathChooser").after(getFileBrowseButton($("input[name='command']")));
+
+    if (typeof CodeMirror !== 'undefined' && $("textarea[name='xml']").length > 0) {
+      $("textarea[name='xml']").hide();
+      var xmlEditor = $("<textarea>").attr("id", "tempXml").prependTo($("textarea[name='xml']").parent())[0];
+      var codeMirror = CodeMirror(function (elt) {
+        xmlEditor.parentNode.replaceChild(elt, xmlEditor);
+      }, {
+        value:$("textarea[name='xml']").val(),
+        lineNumbers:true,
+        autoCloseTags:true
+      });
+      codeMirror.on("update", function () {
+        ko.dataFor($("textarea[name='xml']")[0]).xml(codeMirror.getValue());
+      });
+    }
+    $("*[rel=popover]").each(function(){
+      if ($(this).find("input").length > 0){
+        $(this).popover({
+          placement:'right',
+          trigger:'hover',
+          selector: 'input'
+        });
+      }
+      else {
+        $(this).popover({
+          placement:'right',
+          trigger:'hover'
+        });
+      }
+    });
+    $(".propKey").typeahead({
+      source:(typeof AUTOCOMPLETE_PROPERTIES != 'undefined') ? AUTOCOMPLETE_PROPERTIES : []
+    });
+  }
+
+  return module;
+};

+ 524 - 0
apps/oozie/static/js/workflow.models.js

@@ -0,0 +1,524 @@
+// 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.
+
+// Since knockout maps arrays without calling "update" nor "create"
+// Provide a JSON string that will be parsed in custom 'create' and 'update' functions.
+// These serialized values are also stored in the backend.
+var MODEL_FIELDS_JSON = ['parameters', 'job_properties', 'files', 'archives', 'prepares', 'params',
+                   'deletes', 'mkdirs', 'moves', 'chmods', 'touchzs'];
+function normalize_model_fields(node_model) {
+  $.each(MODEL_FIELDS_JSON, function(index, field) {
+    if (field in node_model && $.isArray(node_model[field])) {
+      node_model[field] = JSON.stringify(node_model[field]);
+    }
+  });
+  return node_model;
+}
+
+// Parse JSON if it is JSON and appropriately map.
+// Apply subscriber to each mapping.
+var map_params = function(options, subscribe) {
+  options.data = ($.type(options.data) == "string") ? $.parseJSON(options.data) : options.data;
+  if ($.isArray(options.data)) {
+    var mapping =  ko.mapping.fromJS(options.data);
+    $.each(mapping(), function(index, value) {
+      subscribe(value);
+    });
+    return mapping;
+  } else {
+    var mapping =  ko.mapping.fromJS(options.data, {});
+    subscribe(mapping);
+    return mapping;
+  }
+};
+
+// Maps JSON strings to fields in the view model.
+var MAPPING_OPTIONS = {
+  ignore: ['initialize', 'toString'],
+  job_properties: {
+    create: function(options) {
+      var parent = options.parent;
+      var subscribe = function(mapping) {
+        mapping.name.subscribe(function(value) {
+          parent.job_properties.valueHasMutated();
+        });
+        mapping.value.subscribe(function(value) {
+          parent.job_properties.valueHasMutated();
+        });
+      };
+
+      return map_params(options, subscribe);
+    },
+    update: function(options) {
+      var parent = options.parent;
+      var subscribe = function(mapping) {
+        mapping.name.subscribe(function(value) {
+          parent.job_properties.valueHasMutated();
+        });
+        mapping.value.subscribe(function(value) {
+          parent.job_properties.valueHasMutated();
+        });
+      };
+
+      return map_params(options, subscribe);
+    }
+  },
+  files: {
+    create: function(options) {
+      return map_params(options, function() {});
+    },
+    update: function(options) {
+      return map_params(options, function() {});
+    },
+  },
+  archives: {
+    create: function(options) {
+      var parent = options.parent;
+      var subscribe = function(mapping) {
+        mapping.name.subscribe(function(value) {
+          parent.archives.valueHasMutated();
+        });
+        mapping.dummy.subscribe(function(value) {
+          parent.archives.valueHasMutated();
+        });
+      };
+
+      return map_params(options, subscribe);
+    },
+    update: function(options) {
+      var parent = options.parent;
+      var subscribe = function(mapping) {
+        mapping.name.subscribe(function(value) {
+          parent.archives.valueHasMutated();
+        });
+        mapping.dummy.subscribe(function(value) {
+          parent.archives.valueHasMutated();
+        });
+      };
+
+      return map_params(options, subscribe);
+    },
+  },
+  params: {
+    create: function(options) {
+      var parent = options.parent;
+      var subscribe = function(mapping) {
+        mapping.value.subscribe(function(value) {
+          parent.params.valueHasMutated();
+        });
+        mapping.type.subscribe(function(value) {
+          parent.params.valueHasMutated();
+        });
+      };
+
+      return map_params(options, subscribe);
+    },
+    update: function(options) {
+      var parent = options.parent;
+      var subscribe = function(mapping) {
+        mapping.value.subscribe(function(value) {
+          parent.params.valueHasMutated();
+        });
+        mapping.type.subscribe(function(value) {
+          parent.params.valueHasMutated();
+        });
+      };
+
+      return map_params(options, subscribe);
+    },
+  },
+  prepares: {
+    create: function(options) {
+      var parent = options.parent;
+      var subscribe = function(mapping) {
+        mapping.value.subscribe(function(value) {
+          parent.prepares.valueHasMutated();
+        });
+        mapping.type.subscribe(function(value) {
+          parent.prepares.valueHasMutated();
+        });
+      };
+
+      return map_params(options, subscribe);
+    },
+    update: function(options) {
+      var parent = options.parent;
+      var subscribe = function(mapping) {
+        mapping.value.subscribe(function(value) {
+          parent.prepares.valueHasMutated();
+        });
+        mapping.type.subscribe(function(value) {
+          parent.prepares.valueHasMutated();
+        });
+      };
+
+      return map_params(options, subscribe);
+    },
+  },
+  deletes: {
+    create: function(options) {
+      return map_params(options, function() {});
+    },
+  },
+  mkdirs: {
+    create: function(options) {
+      return map_params(options, function() {});
+    },
+  },
+  moves: {
+    create: function(options) {
+      var parent = options.parent;
+      var subscribe = function(mapping) {
+        mapping.source.subscribe(function(value) {
+          parent.moves.valueHasMutated();
+        });
+        mapping.destination.subscribe(function(value) {
+          parent.moves.valueHasMutated();
+        });
+      };
+
+      return map_params(options, subscribe);
+    },
+   },
+   chmods: {
+     create: function(options) {
+       var parent = options.parent;
+       var subscribe = function(mapping) {
+         mapping.path.subscribe(function(value) {
+           parent.chmods.valueHasMutated();
+         });
+         mapping.permissions.subscribe(function(value) {
+           parent.chmods.valueHasMutated();
+         });
+         mapping.recursive.subscribe(function(value) {
+           parent.chmods.valueHasMutated();
+         });
+       };
+
+       return map_params(options, subscribe);
+     },
+   },
+   touchzs: {
+     create: function(options) {
+       return map_params(options, function() {});
+     },
+   }
+};
+
+var ModelModule = function($) {
+  var module = function(attrs) {
+    var self = this;
+    $.extend(self, attrs);
+
+    module.prototype.initialize.apply(self, arguments);
+
+    return self;
+  };
+
+  $.extend(module.prototype, {
+    // Normal stuff
+    initialize: function(){},
+
+    toString: function() {
+      var self = this;
+      return JSON.stringify(self, null, '\t');
+    }
+  });
+
+  return module;
+};
+
+var WorkflowModel = ModelModule($);
+$.extend(WorkflowModel.prototype, {
+  id: 0,
+  name: '',
+  description: '',
+  start: 0,
+  end: 0,
+  schema_version: 0.4,
+  deployment_dir: '',
+  is_shared: true,
+  parameters: '[]',
+  job_xml: ''
+});
+
+var NodeModel = ModelModule($);
+$.extend(NodeModel.prototype, {
+  id: 0,
+  name: '',
+  description: '',
+  node_type: '',
+  workflow: 0,
+  child_links: []
+});
+
+var ForkModel = ModelModule($);
+$.extend(ForkModel.prototype, {
+  id: 0,
+  name: '',
+  description: '',
+  node_type: 'fork',
+  workflow: 0,
+  child_links: []
+});
+
+var DecisionModel = ModelModule($);
+$.extend(DecisionModel.prototype, {
+  id: 0,
+  name: '',
+  description: '',
+  node_type: 'decision',
+  workflow: 0,
+  child_links: []
+});
+
+var DistCPModel = ModelModule($);
+$.extend(DistCPModel.prototype, {
+  id: 0,
+  name: '',
+  description: '',
+  node_type: 'distcp',
+  workflow: 0,
+  job_properties: '[]',
+  prepares: '[]',
+  job_xml: '',
+  params: '[]',
+  child_links: []
+});
+
+var MapReduceModel = ModelModule($);
+$.extend(MapReduceModel.prototype, {
+  id: 0,
+  name: '',
+  description: '',
+  node_type: 'mapreduce',
+  workflow: 0,
+  files: '[]',
+  archives: '[]',
+  job_properties: '[]',
+  jar_path: '',
+  prepares: '[]',
+  job_xml: '',
+  child_links: []
+});
+
+var StreamingModel = ModelModule($);
+$.extend(StreamingModel.prototype, {
+  id: 0,
+  name: '',
+  description: '',
+  node_type: 'streaming',
+  workflow: 0,
+  files: '[]',
+  archives: '[]',
+  job_properties: '[]',
+  mapper: '',
+  reducer: '',
+  child_links: []
+});
+
+var JavaModel = ModelModule($);
+$.extend(JavaModel.prototype, {
+  id: 0,
+  name: '',
+  description: '',
+  node_type: 'java',
+  workflow: 0,
+  files: '[]',
+  archives: '[]',
+  job_properties: '[]',
+  jar_path: '',
+  prepares: '[]',
+  job_xml: '',
+  main_class: '',
+  args: '',
+  java_opts: '',
+  child_links: []
+});
+
+var PigModel = ModelModule($);
+$.extend(PigModel.prototype, {
+  id: 0,
+  name: '',
+  description: '',
+  node_type: 'pig',
+  workflow: 0,
+  files: '[]',
+  archives: '[]',
+  job_properties: '[]',
+  prepares: '[]',
+  job_xml: '',
+  params: '[]',
+  script_path: '',
+  child_links: []
+});
+
+var HiveModel = ModelModule($);
+$.extend(HiveModel.prototype, {
+  id: 0,
+  name: '',
+  description: '',
+  node_type: 'hive',
+  workflow: 0,
+  files: '[]',
+  archives: '[]',
+  job_properties: '[]',
+  prepares: '[]',
+  job_xml: '',
+  params: '[]',
+  script_path: '',
+  child_links: []
+});
+
+var SqoopModel = ModelModule($);
+$.extend(SqoopModel.prototype, {
+  id: 0,
+  name: '',
+  description: '',
+  node_type: 'sqoop',
+  workflow: 0,
+  files: '[]',
+  archives: '[]',
+  job_properties: '[]',
+  prepares: '[]',
+  job_xml: '',
+  params: '[]',
+  script_path: '',
+  child_links: []
+});
+
+var ShellModel = ModelModule($);
+$.extend(ShellModel.prototype, {
+  id: 0,
+  name: '',
+  description: '',
+  node_type: 'shell',
+  workflow: 0,
+  files: '[]',
+  archives: '[]',
+  job_properties: '[]',
+  prepares: '[]',
+  job_xml: '',
+  params: '[]',
+  command: '',
+  capture_output: false,
+  child_links: []
+});
+
+var SshModel = ModelModule($);
+$.extend(SshModel.prototype, {
+  id: 0,
+  name: '',
+  description: '',
+  node_type: 'ssh',
+  workflow: 0,
+  user: '',
+  host: '',
+  params: '[]',
+  command: '',
+  capture_output: false,
+  child_links: []
+});
+
+var FsModel = ModelModule($);
+$.extend(FsModel.prototype, {
+  id: 0,
+  name: '',
+  description: '',
+  node_type: 'fs',
+  workflow: 0,
+  deletes: '[]',
+  mkdirs: '[]',
+  moves: '[]',
+  chmods: '[]',
+  touchzs: '[]',
+  child_links: []
+});
+
+var EmailModel = ModelModule($);
+$.extend(EmailModel.prototype, {
+  id: 0,
+  name: '',
+  description: '',
+  node_type: 'email',
+  workflow: 0,
+  to: '',
+  cc: '',
+  subject: '',
+  body: '',
+  child_links: []
+});
+
+var SubWorkflowModel = ModelModule($);
+$.extend(SubWorkflowModel.prototype, {
+  id: 0,
+  name: '',
+  description: '',
+  node_type: 'subworkflow',
+  workflow: 0,
+  sub_workflow: 0,
+  propagate_configuration: true,
+  job_properties: '[]',
+  child_links: []
+});
+
+var GenericModel = ModelModule($);
+$.extend(GenericModel.prototype, {
+  id: 0,
+  name: '',
+  description: '',
+  node_type: 'generic',
+  workflow: 0,
+  xml: '',
+  child_links: []
+});
+
+function nodeModelChooser(node_type) {
+  switch(node_type) {
+    case 'mapreduce':
+      return MapReduceModel;
+    case 'streaming':
+      return StreamingModel;
+    case 'java':
+      return JavaModel;
+    case 'pig':
+      return PigModel;
+    case 'hive':
+      return HiveModel;
+    case 'sqoop':
+      return SqoopModel;
+    case 'shell':
+      return ShellModel;
+    case 'ssh':
+      return SshModel;
+    case 'distcp':
+      return DistCPModel;
+    case 'fs':
+        return FsModel;
+    case 'email':
+        return EmailModel;
+    case 'subworkflow':
+        return SubWorkflowModel;
+    case 'generic':
+        return GenericModel;
+    case 'fork':
+      return ForkModel;
+    case 'decision':
+      return DecisionModel;
+    default:
+      return NodeModel;
+  }
+}

+ 230 - 0
apps/oozie/static/js/workflow.node-fields.js

@@ -0,0 +1,230 @@
+// 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.
+
+/**
+ * Provides file, archive, property, param
+ * arg, argument, EnvVar, prepares, delete,
+ * mkdir, touch, chmod, move, and touchz
+ * field operations.
+ */
+var NodeFields = {
+  removeFile: function(data, event) {
+    var self = this;
+    self.files.remove(data);
+    $(document).trigger('remove.file.workflow', [data]);
+  },
+  addFile: function(data, event) {
+    var self = this;
+    var prop = { name: ko.observable("") };
+    prop.name.subscribe(function(value) {
+      self.files.valueHasMutated();
+    });
+    self.files.push(prop);
+    $(document).trigger('add.file.design', [data]);
+  },
+  removeArchive: function(data, event) {
+    var self = this;
+    self.archives.remove(data);
+    $(document).trigger('remove.archive.workflow', [data]);
+  },
+  addArchive: function(data, event) {
+    var self = this;
+    var prop = { name: ko.observable(""), dummy: ko.observable("") };
+    prop.name.subscribe(function(value) {
+      self.archives.valueHasMutated();
+    });
+    self.archives.push(prop);
+    $(document).trigger('add.archive.workflow', [data]);
+  },
+  removeProperty: function(data, event) {
+    var self = this;
+    self.job_properties.remove(data);
+    $(document).trigger('remove.property.workflow', [data]);
+  },
+  addProperty: function(data, event) {
+    var self = this;
+    var prop = { name: ko.observable(""), value: ko.observable("") };
+    prop.name.subscribe(function(value) {
+      self.job_properties.valueHasMutated();
+    });
+    prop.value.subscribe(function(value) {
+      self.job_properties.valueHasMutated();
+    });
+    self.job_properties.push(prop);
+    $(document).trigger('add.property.workflow', [data]);
+  },
+  addParam: function(data, event) {
+    var self = this;
+    var prop = { value: ko.observable(""), type: ko.observable("param") };
+    prop.value.subscribe(function(value) {
+      self.params.valueHasMutated();
+    });
+    prop.type.subscribe(function(value) {
+      self.params.valueHasMutated();
+    });
+    self.params.push(prop);
+    $(document).trigger('add.param.workflow', [data]);
+  },
+  addArgument: function(data, event) {
+    var self = this;
+    var prop = { value: ko.observable(""), type: ko.observable("argument") };
+    prop.value.subscribe(function(value) {
+      self.params.valueHasMutated();
+    });
+    prop.type.subscribe(function(value) {
+      self.params.valueHasMutated();
+    });
+    self.params.push(prop);
+    $(document).trigger('add.argument.workflow', [data]);
+  },
+  addArg: function(data, event) {
+    var self = this;
+    var prop = { value: ko.observable(""), type: ko.observable("arg") };
+    prop.value.subscribe(function(value) {
+      self.params.valueHasMutated();
+    });
+    prop.type.subscribe(function(value) {
+      self.params.valueHasMutated();
+    });
+    self.params.push(prop);
+    $(document).trigger('add.arg.workflow', [data]);
+  },
+  addEnvVar: function(data, event) {
+    var self = this;
+    var prop = { value: ko.observable(""), type: ko.observable("env-var") };
+    prop.value.subscribe(function(value) {
+      self.params.valueHasMutated();
+    });
+    prop.type.subscribe(function(value) {
+      self.params.valueHasMutated();
+    });
+    self.params.push(prop);
+    $(document).trigger('add.envvar.workflow', [data]);
+  },
+  removeParam: function(data, event) {
+    var self = this;
+    self.params.remove(data);
+    $(document).trigger('remove.param.workflow', [data]);
+  },
+  addPrepareDelete: function(data, event) {
+    var self = this;
+    var prop = { value: ko.observable(""), type: ko.observable("delete") };
+    prop.value.subscribe(function(value) {
+      self.prepares.valueHasMutated();
+    });
+    prop.type.subscribe(function(value) {
+      self.prepares.valueHasMutated();
+    });
+    self.prepares.push(prop);
+    $(document).trigger('add.prepare_delete.workflow', [data]);
+  },
+  addPrepareMkdir: function(data, event) {
+    var self = this;
+    var prop = { value: ko.observable(""), type: ko.observable("mkdir") };
+    prop.value.subscribe(function(value) {
+      self.prepares.valueHasMutated();
+    });
+    prop.type.subscribe(function(value) {
+      self.prepares.valueHasMutated();
+    });
+    self.prepares.push(prop);
+    $(document).trigger('add.prepare_mkdir.workflow', [data]);
+  },
+  removePrepare: function(data, event) {
+    var self = this;
+    self.prepares.remove(data);
+    $(document).trigger('remove.prepare.workflow', [data]);
+  },
+  addDelete: function(data, event) {
+    var self = this;
+    var prop = { name: ko.observable("") };
+    prop.name.subscribe(function(value) {
+      self.deletes.valueHasMutated();
+    });
+    self.deletes.push(prop);
+    $(document).trigger('add.delete.workflow', [data]);
+  },
+  removeDelete: function(data, event) {
+    var self = this;
+    self.deletes.remove(data);
+    $(document).trigger('remove.delete.workflow', [data]);
+  },
+  addMkdir: function(data, event) {
+    var self = this;
+    var prop = { name: ko.observable("") };
+    prop.name.subscribe(function(value) {
+      self.mkdirs.valueHasMutated();
+    });
+    self.mkdirs.push(prop);
+    $(document).trigger('add.mkdir.workflow', [data]);
+  },
+  removeMkdir: function(data, event) {
+    var self = this;
+    self.mkdirs.remove(data);
+    $(document).trigger('remove.mkdir.workflow', [data]);
+  },
+  addMove: function(data, event) {
+    var self = this;
+    var prop = { source: ko.observable(""), destination: ko.observable("") };
+    prop.source.subscribe(function(value) {
+      self.moves.valueHasMutated();
+    });
+    prop.destination.subscribe(function(value) {
+      self.moves.valueHasMutated();
+    });
+    self.moves.push(prop);
+    $(document).trigger('add.move.workflow', [data]);
+  },
+  removeMove: function(data, event) {
+    var self = this;
+    self.moves.remove(data);
+    $(document).trigger('remove.move.workflow', [data]);
+  },
+  addChmod: function(data, event) {
+    var self = this;
+    var prop = { path: ko.observable(""), permissions: ko.observable(""), recursive: ko.observable("") };
+    prop.path.subscribe(function(value) {
+      self.chmods.valueHasMutated();
+    });
+    prop.permissions.subscribe(function(value) {
+      self.chmods.valueHasMutated();
+    });
+    prop.recursive.subscribe(function(value) {
+      self.chmods.valueHasMutated();
+    });
+    self.chmods.push(prop);
+    $(document).trigger('add.chmod.workflow', [data]);
+  },
+  removeChmod: function(data, event) {
+    var self = this;
+    self.chmods.remove(data);
+    $(document).trigger('remove.chmod.workflow', [data]);
+  },
+  addTouchz: function(data, event) {
+    var self = this;
+    var prop = { name: ko.observable("") };
+    prop.name.subscribe(function(value) {
+      self.touchzs.valueHasMutated();
+    });
+    self.touchzs.push(prop);
+    $(document).trigger('add.touchz.workflow', [data]);
+  },
+  removeTouchz: function(data, event) {
+    var self = this;
+    self.touchzs.remove(data);
+    $(document).trigger('remove.touchz.workflow', [data]);
+  }
+};

+ 438 - 0
apps/oozie/static/js/workflow.node.js

@@ -0,0 +1,438 @@
+// 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.
+
+/**
+ * Node
+ * Displays node in a graph and handles graph manipulation.
+ * The majority of nodes require similar logic.
+ * This modules takes advantage of that fact.
+ */
+var NodeModule = function($, IdGeneratorTable, NodeFields) {
+  var META_LINKS = ['related', 'default', 'error'];
+
+  var linkTypeChooser = function(parent, child) {
+    if (child.node_type() == 'kill') {
+      return 'error';
+    }
+    switch(parent.node_type()) {
+      case 'start':
+        return (child.node_type() == 'end') ? 'related' : 'to';
+      case 'fork':
+        return (child.node_type() == 'join') ? 'related' : 'start';
+      case 'decision':
+        return (child.node_type() == 'decisionend') ? 'related' : 'start';
+      case 'join':
+      case 'decisionend':
+        return 'to';
+      default:
+        return 'ok';
+    };
+  };
+
+  var module = function(workflow, model, registry) {
+    var self = this;
+
+    self.map(model);
+
+    self.links = ko.computed(function() {
+      var links = self.child_links().filter(function(element, index, arr) {
+        return $.inArray(element.name(), META_LINKS) == -1;
+      });
+      return links;
+    });
+
+    self.meta_links = ko.computed(function() {
+      var links = self.child_links().filter(function(element, index, arr) {
+        return $.inArray(element.name(), META_LINKS) != -1;
+      });
+      return links;
+    });
+
+    self._workflow = workflow;
+
+    self.registry = registry;
+    self.children = ko.observableArray([]);
+    self.model = model;
+
+    var errors = {};
+    for(var key in model) {
+      switch(key) {
+        case 'child_links':
+        case 'node_ptr':
+        case 'initialize':
+        case 'toString':
+        break;
+        default:
+          errors[key] = [];
+        break;
+      }
+    }
+    self.errors = ko.mapping.fromJS(errors);
+
+    self.edit_template = model.node_type + 'EditTemplate';
+    switch(model.node_type) {
+    case 'start':
+      self.view_template = ko.observable('startTemplate');
+    break;
+
+    case 'kill':
+    case 'end':
+      self.view_template = ko.observable('emptyTemplate');
+    break;
+
+    case 'fork':
+      self.view_template = ko.observable('forkTemplate');
+    break;
+
+    case 'join':
+      self.view_template = ko.observable('joinTemplate');
+    break;
+
+    case 'decision':
+      self.view_template = ko.observable('decisionTemplate');
+    break;
+
+    case 'decisionend':
+      self.view_template = ko.observable('decisionEndTemplate');
+    break;
+
+    default:
+      self.view_template = ko.observable('nodeTemplate');
+    break;
+    }
+
+    // Data manipulation
+    if ('files' in model) {
+      //// WARNING: The following order should be preserved!
+
+      // Need to represent files as some thing else for knockout mappings.
+      // The KO idiom "value" requires a named parameter.
+      self._files = self.files;
+      self.files = ko.observableArray([]);
+
+      // ['file', ...] => [{'name': 'file', 'dummy': ''}, ...].
+      $.each(self._files(), function(index, filename) {
+        var prop = { name: ko.observable(filename), dummy: ko.observable("") };
+        prop.name.subscribe(function(value) {
+          self.files.valueHasMutated();
+        });
+        prop.dummy.subscribe(function(value) {
+          self.files.valueHasMutated();
+        });
+        self.files.push(prop);
+      });
+
+      // [{'name': 'file', 'dummy': ''}, ...] => ['file', ...].
+      self.files.subscribe(function(value) {
+        self._files.removeAll();
+        $.each(self.files(), function(index, file) {
+          self._files.push(file.name);
+        });
+      });
+
+      self.addFile = function() {
+        var prop = { name: ko.observable(""), dummy: ko.observable("") };
+        prop.name.subscribe(function(value) {
+          self.files.valueHasMutated();
+        });
+        prop.dummy.subscribe(function(value) {
+          self.files.valueHasMutated();
+        });
+        self.files.push(prop);
+      };
+
+      self.removeFile = function(val) {
+        self.files.remove(val);
+      };
+    }
+
+    self.initialize.apply(self, arguments);
+
+    return self;
+  };
+
+  $.extend(true, module.prototype, NodeFields, {
+    // Data.
+    children: null,
+    model: null,
+
+    // Normal stuff
+    /**
+     * Called when creating a new node
+     */
+    initialize: function(workflow, model, registry) {},
+
+    toString: function() {
+      return '';
+    },
+
+    /**
+     * Fetches registry
+     */
+    getRegistry: function() {
+      return registry;
+    },
+
+    /**
+     * Maps a model to self
+     * Called when creating a new node before any thing else
+     */
+    map: function(model) {
+      var self = this;
+
+      // @see http://knockoutjs.com/documentation/plugins-mapping.html
+      // MAPPING_OPTIONS comes from /oozie/static/js/models.js
+      var mapping = ko.mapping.fromJS(model, MAPPING_OPTIONS);
+
+      $.extend(self, mapping);
+      $.each(mapping, function(key, value) {
+        var key = key;
+        if (ko.isObservable(self[key])) {
+          self[key].subscribe(function(value) {
+            model[key] = ko.mapping.toJS(value);
+          });
+        }
+      });
+
+      $.each(self.child_links(), function(index, link) {
+        var $index = index;
+        link.comment.subscribe(function(value) {
+          self.model.child_links[$index].comment = value;
+        });
+
+        link.child.subscribe(function(value) {
+          self.model.child_links[$index].child = value;
+        });
+      });
+
+    },
+
+    validate: function( ) {
+      var self = this;
+
+      var options = {};
+
+      data = $.extend(true, {}, self.model);
+
+      var success = false;
+      var request = $.extend({
+        url: '/oozie/workflows/' + self._workflow.id() + '/nodes/' + self.node_type() + '/validate',
+        type: 'POST',
+        data: { node: JSON.stringify(data) },
+        success: function(data) {
+          ko.mapping.fromJS(data.data, self.errors);
+          success = data.status == 0;
+        },
+        async: false
+      }, options);
+
+      $.ajax(request);
+
+      return success;
+    },
+
+    // Hierarchy manipulation.
+    /**
+     * Append node to self
+     * Does not support multiple children.
+     * Ensures single child.
+     * Ensures no cycles.
+     * 1. Finds all children and attaches them to node (cleans node first).
+     * 2. Remove all children from self.
+     * 3. Attach node to self.
+     */
+    append: function(node) {
+      var self = this;
+
+      // Not fork nor decision nor self
+      if ($.inArray(self.node_type(), ['fork', 'decision']) == -1 && node.id() != self.id() && !self.isChild(node)) {
+        node.removeAllChildren();
+        $.each(self.links(), function(index, link) {
+          node.addChild(self.registry.get(link.child()));
+        });
+        self.removeAllChildren();
+        self.addChild(node);
+      }
+    },
+
+    /**
+     * Find all parents of current node
+     */
+    findParents: function() {
+      var self = this;
+
+      var parents = [];
+      $.each(self.registry.nodes, function(id, node) {
+        $.each(node.links(), function(index, link) {
+          if (link.child() == self.id()) {
+            parents.push(node);
+          }
+        });
+      });
+      return parents;
+    },
+
+    /**
+     * Find all children of current node
+     */
+    findChildren: function() {
+      var self = this;
+
+      var children = [];
+      $.each(self.links(), function(index, link) {
+        children.push(self.registry.get(link.child()));
+      });
+
+      return children;
+    },
+
+    /**
+     * Detach current node from the graph
+     * 1. Takes children of self node, removes them from self node, and adds them to each parent of self node.
+     * 2. The self node is then removed from every parent.
+     * 3. Does not support multiple children since we do not automatically fork.
+     */
+    detach: function() {
+      var self = this;
+
+      $.each(self.findParents(), function(index, parent) {
+        $.each(self.links(), function(index, link) {
+          var node = self.registry.get(link.child());
+          parent.replaceChild(self, node);
+        });
+      });
+
+      $(self).trigger('detached');
+
+      self.removeAllChildren();
+    },
+
+    /**
+     * Add child
+     * Update child links for this node.
+     */
+    addChild: function(node) {
+      var self = this;
+      var link = {
+        parent: ko.observable(self.id()),
+        child: ko.observable(node.id()),
+        name: ko.observable(linkTypeChooser(self, node)),
+        comment: ko.observable('')
+      };
+      self.child_links.unshift(link);
+    },
+
+    /**
+     * Remove child node
+     * 1. Find child node link
+     * 2. Remove child node link
+     */
+    removeChild: function(node) {
+      var self = this;
+      var spliceIndex = -1;
+
+      $.each(self.child_links(), function(index, link) {
+        if (link.child() == node.id()) {
+          spliceIndex = index;
+        }
+      });
+
+      if (spliceIndex > -1) {
+        self.child_links.splice(spliceIndex, 1);
+      }
+
+      return spliceIndex != -1;
+    },
+
+    /**
+     * Remove all children
+     * Removes all children except for related, default, and error links
+     * Note: we hold on to related, default, and error links because
+     *  we have to.
+     */
+    removeAllChildren: function() {
+      var self = this;
+      var keep_links = [];
+
+      $.each(self.child_links(), function(index, link) {
+        if ($.inArray(link.name(), META_LINKS) > -1) {
+          keep_links.push(link);
+        }
+      });
+
+      self.child_links.removeAll();
+      $.each(keep_links, function(index, link) {
+        self.child_links.push(link);
+      });
+    },
+
+    /**
+     * Replace child node with another node in the following way:
+     * 1. Find child index
+     * 2. Remove child index
+     * 3. Remove and remember every element after child
+     * 4. Add replacement node
+     * 5. Add every child that was remembered
+     */
+    replaceChild: function(child, replacement) {
+      var self = this;
+      var index = -1;
+
+      $.each(self.child_links(), function(i, link) {
+        if (link.child() == child.id()) {
+          index = i;
+        }
+      });
+
+      if (index > -1) {
+        self.child_links.splice(index, 1);
+        var links = self.child_links.splice(index);
+        var link = {
+          parent: ko.observable(self.id()),
+          child: ko.observable(replacement.id()),
+          name: ko.observable(linkTypeChooser(self, replacement)),
+          comment: ko.observable('')
+        };
+        self.child_links.push(link);
+
+        $.each(links, function(index, link) {
+          self.child_links.push(link);
+        });
+      }
+
+      return index != -1;
+    },
+
+    isChild: function(node) {
+      var self = this;
+      var res = false;
+      $.each(self.links(), function(index, link) {
+        if (link.child() == node.id()) {
+          res = true;
+        }
+      });
+      return res;
+    },
+
+    erase: function() {
+      var self = this;
+      self.registry.remove(self.id());
+    }
+    
+  });
+
+  return module;
+};

+ 71 - 0
apps/oozie/static/js/workflow.registry.js

@@ -0,0 +1,71 @@
+// 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.
+
+/**
+ * Registry of models
+ *  - Each model should have an ID attribute.
+ */
+var RegistryModule = function($) {
+  var module = function() {
+    var self = this;
+
+    self.nodes = {};
+
+    module.prototype.initialize.apply(self, arguments);
+
+    return self;
+  };
+
+  $.extend(module.prototype, {
+    // Normal stuff
+    initialize: function() {},
+
+    toString: function() {
+      var self = this;
+
+      var s = $.map(self.nodes, function(node) {
+        return node.id();
+      }).join();
+      return s;
+    },
+
+    add: function(id, node) {
+      var self = this;
+      $(self).trigger('registry:add');
+      self.nodes[String(id)] = node;
+    },
+
+    remove: function(id) {
+      var self = this;
+      $(self).trigger('registry:remove');
+      delete self.nodes[String(id)];
+    },
+
+    get: function(id) {
+      var self = this;
+      return self.nodes[id];
+    },
+
+    clear: function() {
+      var self = this;
+
+      delete self.nodes;
+      self.nodes = {};
+    }
+  });
+
+  return module;
+};

+ 26 - 0
apps/oozie/static/js/workflow.utils.js

@@ -0,0 +1,26 @@
+// 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.
+
+// adding missing .filter for IE8
+if (!('filter' in Array.prototype)) {
+  Array.prototype.filter= function(filter, that /*opt*/) {
+    var other= [], v;
+    for (var i=0, n= this.length; i<n; i++)
+      if (i in this && filter.call(that, v= this[i], i, this))
+        other.push(v);
+    return other;
+  };
+}

Niektóre pliki nie zostały wyświetlone z powodu dużej ilości zmienionych plików