Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 22 additions & 1 deletion apps/dashboard/app/controllers/workflows_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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[<smart_attribute_id>]".
# 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
Expand Down Expand Up @@ -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
116 changes: 116 additions & 0 deletions apps/dashboard/app/javascript/workflow_advanced.js
Original file line number Diff line number Diff line change
@@ -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);
});
}
});
20 changes: 17 additions & 3 deletions apps/dashboard/app/models/workflow.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand All @@ -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
Expand All @@ -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

Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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

Expand Down
47 changes: 47 additions & 0 deletions apps/dashboard/app/views/workflows/_advanced.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
<%= javascript_include_tag 'launcher_edit', nonce: true, defer: true %>
<%= javascript_include_tag 'workflow_advanced', nonce: true, defer: true %>

<div class="card mt-3" id="workflow_advanced_card">
<div class="card-body">
<h5 class="card-title">
<%= t('dashboard.jobs_workflow_advanced_overrides_title') %>
</h5>

<p class="text-muted small mb-3">
<%= t('dashboard.jobs_workflow_advanced_overrides_description') %>
</p>

<% @workflow_overrides.each do |attrib| %>
<%= create_editable_widget(script_form_double, attrib, format: nil) %>
<% end %>

<%= render partial: 'launchers/add_new_field' %>
</div>
</div>

<%# Refer app/lib/smart_attributes.rb to check for other attributes to add here%>
<template id="bc_num_hours_template">
<%= bc_num_hours_template %>
</template>

<template id="auto_queues_template">
<%= auto_queues_template %>
</template>

<template id="auto_accounts_template">
<%= auto_accounts_template %>
</template>

<template id="auto_job_name_template">
<%= auto_job_name_template %>
</template>

<template id="auto_environment_variable_template">
<%= auto_environment_variable_template %>
</template>

<template id="auto_batch_clusters_template">
<%# Reuse the same widget renderer the launcher edit page uses. %>
<%= create_editable_widget(script_form_double,
SmartAttributes::AttributeFactory.build('auto_batch_clusters', {})) %>
</template>
40 changes: 31 additions & 9 deletions apps/dashboard/app/views/workflows/_form.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -43,22 +43,44 @@

<div class="field mt-3">
<strong>Synchronization</strong><br>
<div class="form-check form-switch mt-2">
<%= 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" %>
<div class="row align-items-center mt-2">
<div class="col">
<div class="form-check form-switch">
<%= 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" %>
</div>
</div>
<div class="col-auto">
<button class="btn btn-outline-secondary btn-sm"
type="button"
data-bs-toggle="collapse"
data-bs-target="#workflow_advanced_collapse"
aria-expanded="false"
aria-controls="workflow_advanced_collapse"
id="workflow_advanced_toggle">
<%= I18n.t('dashboard.jobs_workflow_advanced_button') %>
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>

<% if @workflow_overrides %>
<div class="collapse" id="workflow_advanced_collapse">
<%= render partial: 'advanced' %>
</div>
<% end %>

<br>
<p>
<%= form.submit I18n.t('dashboard.save'), class: 'btn btn-primary', title: 'Save project' %>
Expand Down
3 changes: 3 additions & 0 deletions apps/dashboard/config/locales/en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 <code>OOD_WORKFLOW_SYNC_KEY</code> 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
Expand Down
Loading