diff --git a/apps/dashboard/app/controllers/workflows_controller.rb b/apps/dashboard/app/controllers/workflows_controller.rb index e72b8219e7..6ff11720fc 100644 --- a/apps/dashboard/app/controllers/workflows_controller.rb +++ b/apps/dashboard/app/controllers/workflows_controller.rb @@ -16,12 +16,14 @@ def show def new @workflow = Workflow.new(index_params) @launchers = Launcher.all(project_directory) + @workflow_overrides = @workflow.override_attributes end # GET /projects/:id/workflows/edit def edit return unless load_project_and_workflow_objects @launchers = Launcher.all(project_directory) + @workflow_overrides = @workflow.override_attributes end # TODO to remove this with launcher_ids as we will need them after new UI @@ -54,9 +56,11 @@ def clone cloned = @workflow.deep_dup cloned.name += " (Copy)" session[:cloned_metadata] = cloned.metadata.to_h.deep_stringify_keys + session[:cloned_advanced_overrides] = cloned.advanced_overrides.to_h.deep_stringify_keys @workflow = cloned @launchers = Launcher.all(project_directory) + @workflow_overrides = @workflow.override_attributes render :new end @@ -149,13 +153,29 @@ def permit_params params .require(:workflow) .permit(:name, :description, :id, :sync_key_enabled, launcher_ids: []) - .merge(project_dir: project_directory, metadata: session.delete(:cloned_metadata) || {}) + .merge( + project_dir: project_directory, metadata: session.delete(:cloned_metadata) || {}, + advanced_overrides: (extract_advanced_overrides || session.delete(:cloned_advanced_overrides) || {}).stringify_keys + ) end def update_params params .require(:workflow) .permit(:name, :description, :id, :sync_key_enabled, launcher_ids: []) + .merge(advanced_overrides: (extract_advanced_overrides || {}).stringify_keys) + end + + # We use launcher's smart-attribute widgets, which render fields with name="launcher[]". + # The overrides arrive as params[:launcher], not under params[:workflow]. + def extract_advanced_overrides + raw = params[:launcher] + return nil if raw.blank? + raw = raw.to_unsafe_h if raw.respond_to?(:to_unsafe_h) + + raw.to_h.reject do |k, v| + k.to_s.end_with?('_min', '_max', '_exclude', '_fixed') || v.blank? + end end def project_directory @@ -191,6 +211,7 @@ def handle_workflow_error(operation) flash.now[:alert] = message @launchers = Launcher.all(project_directory) + @workflow_overrides = @workflow.override_attributes render operation == :create ? :new : :edit end end \ No newline at end of file diff --git a/apps/dashboard/app/javascript/workflow_advanced.js b/apps/dashboard/app/javascript/workflow_advanced.js new file mode 100644 index 0000000000..4eb523c53f --- /dev/null +++ b/apps/dashboard/app/javascript/workflow_advanced.js @@ -0,0 +1,116 @@ +'use strict'; + +/* + * launcher_edit.js drives the "Add new option" dropdown from its own newFieldData object, + * which lists every launcher field. On the workflow page we only want to expose a subset of the options. + */ + +const ALLOWED = [ + 'auto_accounts', + 'auto_environment_variable', + 'auto_queues', + 'auto_batch_clusters', + 'bc_num_hours', + 'auto_job_name' +]; +const REPEATABLE = ['auto_environment_variable']; + +// Mirrors the labels in launcher_edit.js's newFieldData, used when we +// need to re-insert a repeatable option that launcher_edit.js skipped. +const REPEATABLE_LABELS = { + auto_environment_variable: 'Environment Variable' +}; + +function filterDropdown(selectEl) { + Array.from(selectEl.options).forEach((opt) => { + if (ALLOWED.indexOf(opt.value) === -1) { + opt.remove(); + } + }); + + REPEATABLE.forEach((id) => { + if (ALLOWED.indexOf(id) === -1) return; + const present = Array.from(selectEl.options).some((opt) => opt.value === id); + if (!present) { + const opt = document.createElement('option'); + opt.value = id; + opt.text = REPEATABLE_LABELS[id] || id; + selectEl.add(opt); + } + }); +} + +function refreshAddButtonLabel() { + const btn = document.getElementById('add_new_field_button'); + if (!btn) return; + + const remaining = ALLOWED.some((id) => { + if (REPEATABLE.indexOf(id) !== -1) return true; + return document.getElementById('launcher_' + id) === null; + }); + btn.textContent = remaining ? 'Add new option' : 'No more options'; + btn.disabled = !remaining; +} + +// launcher_edit.js remove/edit click handler uses: $('.new_launcher').find('.editable-form-field')... +// The workflow form has no `.new_launcher` thus Remove button does nothing. +// Newly-added fields are unaffected because addInProgressField attach handlers. +function wireExistingFieldHandlers() { + const card = document.getElementById('workflow_advanced_card'); + if (!card) return; + + card.querySelectorAll('.editable-form-field .btn-danger').forEach((btn) => { + btn.addEventListener('click', (event) => { + const entireDiv = event.target.parentElement; + entireDiv.remove(); + refreshAddButtonLabel(); + }); + }); + + card.querySelectorAll('.editable-form-field .btn-primary').forEach((editBtn) => { + editBtn.addEventListener('click', (event) => { + const entireDiv = event.target.parentElement; + const editField = entireDiv.querySelector('.edit-group'); + if (editField) editField.classList.remove('d-none'); + + const saveButton = entireDiv.querySelector('.btn-success'); + if (saveButton) saveButton.classList.remove('d-none'); + event.target.disabled = true; + + if (saveButton) { + saveButton.onclick = (e) => { + const div = e.target.parentElement; + const ef = div.querySelector('.edit-group'); + if (ef) ef.classList.add('d-none'); + const sb = div.querySelector('.btn-success'); + const eb = div.querySelector('.btn-primary'); + if (sb) sb.classList.add('d-none'); + if (eb) eb.disabled = false; + }; + } + }); + }); +} + +document.addEventListener('DOMContentLoaded', () => { + const btn = document.getElementById('add_new_field_button'); + if (!btn) return; + + // We wait one tick after that click fires so the options exist + btn.addEventListener('click', () => { + setTimeout(() => { + const sel = document.getElementById('add_new_field_select'); + if (sel) filterDropdown(sel); + }, 0); + }); + + wireExistingFieldHandlers(); + refreshAddButtonLabel(); + + const card = document.getElementById('workflow_advanced_card'); + if (card) { + card.addEventListener('click', () => { + setTimeout(refreshAddButtonLabel, 0); + }); + } +}); diff --git a/apps/dashboard/app/models/workflow.rb b/apps/dashboard/app/models/workflow.rb index 7326ecf83a..1b6243f0a6 100644 --- a/apps/dashboard/app/models/workflow.rb +++ b/apps/dashboard/app/models/workflow.rb @@ -51,7 +51,8 @@ def generate_sync_key end end - attr_accessor :id, :name, :description, :project_dir, :created_at, :launcher_ids, :metadata, :sync_key_enabled + attr_accessor :id, :name, :description, :project_dir, :created_at, :launcher_ids, + :metadata, :sync_key_enabled, :advanced_overrides validates :name, presence: true validates :launcher_ids, length: {minimum: 1} @@ -65,6 +66,7 @@ def initialize(attributes = {}) @launcher_ids = attributes[:launcher_ids] || [] @metadata = attributes[:metadata] || {} @sync_key_enabled = attributes[:sync_key_enabled] || "0" + @advanced_overrides = attributes[:advanced_overrides] || {} end def to_h @@ -76,7 +78,8 @@ def to_h :project_dir => project_dir, :launcher_ids => launcher_ids, :metadata => metadata, - :sync_key_enabled => sync_key_enabled + :sync_key_enabled => sync_key_enabled, + :advanced_overrides => advanced_overrides } end @@ -127,7 +130,7 @@ def update(attributes, override = false) end def update_attrs(attributes, override = false) - [:name, :description, :launcher_ids, :metadata, :sync_key_enabled].each do |attribute| + [:name, :description, :launcher_ids, :metadata, :sync_key_enabled, :advanced_overrides].each do |attribute| next unless override || attributes.key?(attribute) instance_variable_set("@#{attribute}".to_sym, attributes.fetch(attribute, '')) end @@ -137,6 +140,12 @@ def editable? manifest_file.writable? || !shared?(manifest_file) end + def override_attributes + advanced_overrides.map do |id, value| + SmartAttributes::AttributeFactory.build(id.to_s, { value: value }) + end + end + def submit(attributes = {}) graph = Dag.new(attributes) if graph.has_cycle @@ -188,6 +197,11 @@ def submit_launcher_params(launcher, dependent_jobs, sync_key) end launcher_data["afterok"] = Array(dependent_jobs) launcher_data["ood_workflow_sync_key"] = sync_key if sync_key + advanced_overrides.each do |key, value| + next if value.blank? + launcher_data[key.to_s] = value + end + launcher_data end diff --git a/apps/dashboard/app/views/workflows/_advanced.html.erb b/apps/dashboard/app/views/workflows/_advanced.html.erb new file mode 100644 index 0000000000..c09064a391 --- /dev/null +++ b/apps/dashboard/app/views/workflows/_advanced.html.erb @@ -0,0 +1,47 @@ +<%= javascript_include_tag 'launcher_edit', nonce: true, defer: true %> +<%= javascript_include_tag 'workflow_advanced', nonce: true, defer: true %> + +
+
+
+ <%= t('dashboard.jobs_workflow_advanced_overrides_title') %> +
+ +

+ <%= t('dashboard.jobs_workflow_advanced_overrides_description') %> +

+ + <% @workflow_overrides.each do |attrib| %> + <%= create_editable_widget(script_form_double, attrib, format: nil) %> + <% end %> + + <%= render partial: 'launchers/add_new_field' %> +
+
+ +<%# Refer app/lib/smart_attributes.rb to check for other attributes to add here%> + + + + + + + + + + + diff --git a/apps/dashboard/app/views/workflows/_form.html.erb b/apps/dashboard/app/views/workflows/_form.html.erb index a869a0c19e..33077b7e0e 100644 --- a/apps/dashboard/app/views/workflows/_form.html.erb +++ b/apps/dashboard/app/views/workflows/_form.html.erb @@ -43,15 +43,30 @@
Synchronization
-
- <%= hidden_field_tag "workflow[sync_key_enabled]", "0" %> - <%= check_box_tag "workflow[sync_key_enabled]", "1", - @workflow.sync_key_enabled == "1", - id: "workflow_sync_key_enabled", - class: "form-check-input" %> - <%= label_tag "workflow_sync_key_enabled", - "Enable OOD_WORKFLOW_SYNC_KEY #{form_label_tooltip(I18n.t('dashboard.jobs_workflow_sync_key_help'))}".html_safe, - class: "form-check-label" %> +
+
+
+ <%= hidden_field_tag "workflow[sync_key_enabled]", "0" %> + <%= check_box_tag "workflow[sync_key_enabled]", "1", + @workflow.sync_key_enabled == "1", + id: "workflow_sync_key_enabled", + class: "form-check-input" %> + <%= label_tag "workflow_sync_key_enabled", + "Enable OOD_WORKFLOW_SYNC_KEY #{form_label_tooltip(I18n.t('dashboard.jobs_workflow_sync_key_help'))}".html_safe, + class: "form-check-label" %> +
+
+
+ +
@@ -59,6 +74,13 @@ + +<% if @workflow_overrides %> +
+ <%= render partial: 'advanced' %> +
+<% end %> +

<%= form.submit I18n.t('dashboard.save'), class: 'btn btn-primary', title: 'Save project' %> diff --git a/apps/dashboard/config/locales/en.yml b/apps/dashboard/config/locales/en.yml index c06e652d18..563375bb74 100644 --- a/apps/dashboard/config/locales/en.yml +++ b/apps/dashboard/config/locales/en.yml @@ -243,6 +243,9 @@ en: jobs_workflow_submitted: Workflow successfully submitted! jobs_workflow_help: Click title to select and drag. Connect by clicking two launchers with 'Connect Launchers' selected jobs_workflow_sync_key_help: When enabled, every launcher in this workflow receives the same OOD_WORKFLOW_SYNC_KEY environment variable (a random token unique to each workflow run). Useful for generating synchronized filenames, lock files, or shared identifiers across dependent jobs. + jobs_workflow_advanced_button: Advanced + jobs_workflow_advanced_overrides_title: Advanced Overrides + jobs_workflow_advanced_overrides_description: Optional. Use these fields to match/override (account, cluster, environment variables..) across all launchers at workflow submit. They are not written back to launcher form. jobs_workflows: Workflows launch: Launch logo_alt_text: Welcome to Open OnDemand