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
1 change: 1 addition & 0 deletions apps/dashboard/app/assets/stylesheets/projects.scss
Original file line number Diff line number Diff line change
Expand Up @@ -48,4 +48,5 @@
color: white;
width: 100%;
margin: 0.25rem;
text-wrap:nowrap;
}
65 changes: 65 additions & 0 deletions apps/dashboard/app/assets/stylesheets/workflows.scss
Original file line number Diff line number Diff line change
Expand Up @@ -213,3 +213,68 @@ svg.edges {
.launcher-box.connect-queued {
outline: 3px dashed var(--accent);
}

.launcher-box.running {
background: #f5f5f5;
border: 3px solid #81c784;
box-shadow: 0 0 6px #a5d6a7;
}

.launcher-box.completed {
background: #c8e6c9;
border: 3px solid #388e3c;
box-shadow: 0 0 6px #81c784;
}

.launcher-box.failed {
background: #ffcdd2;
border: 3px solid #e53935;
box-shadow: 0 0 6px #ef9a9a;
}

.launcher-box.pending {
background: #f5f5f5;
border: 3px dashed #bdbdbd;
color: #9e9e9e;
opacity: 0.9;
}

.job-info-overlay {
position: absolute;
inset: 0;
background-color: rgba(0, 0, 0, 0.4);
display: flex;
align-items: center;
justify-content: center;
z-index: 9999;
}

.job-info-content {
position: relative;
background: #fff;
border-radius: 12px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
max-width: 400px;
max-height: 400px;
overflow: auto;
padding: 1.25rem;
font-size: 0.7rem;
transform: translate(20px, 20px);
}

.job-info-close {
position: absolute;
top: 10px;
right: 15px;
background: transparent;
border: none;
font-size: 1.75rem;
line-height: 1;
color: var(--ink-muted);
cursor: pointer;
transition: color 0.15s ease;

&:hover {
color: var(--danger);
}
}
1 change: 1 addition & 0 deletions apps/dashboard/app/controllers/launchers_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ def render_button
launcher = Launcher.find(show_launcher_params[:id], @project.directory)
@valid_project = Launcher.clusters?
@remove_delete_button = true
@show_job_info_button = true
render(partial: 'projects/launcher_buttons', locals: { launcher: launcher })
end

Expand Down
4 changes: 2 additions & 2 deletions apps/dashboard/app/controllers/workflows_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -94,8 +94,8 @@ def submit
metadata = metadata_params(permit_json_data)
@workflow.update(metadata)
result = @workflow.submit(submit_params(metadata))
if result
render json: { message: "Workflow submitted successfully" }
if !result.nil?
render json: { message: I18n.t('dashboard.jobs_workflow_submitted'), job_hash: result }
else
msg = I18n.t('dashboard.jobs_workflow_failed', error: @workflow.collect_errors)
render json: { message: msg }, status: :unprocessable_entity
Expand Down
169 changes: 162 additions & 7 deletions apps/dashboard/app/javascript/workflows.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { DAG } from './dag.js';
import { rootPath } from './config.js';

/*
* Helper Classes to support Drag and Drop UI
Expand All @@ -18,6 +19,8 @@ class WorkflowState {
this.submitUrl = `${baseUrl}/submit`;
this.loadUrl = `${baseUrl}/load`;
this.STORAGE_KEY = `project_${projectId}_workflow_${workflowId}_state`;
this.poller = new JobPoller(projectId);
this.job_hash = {};
}

resetWorkflow(e) {
Expand All @@ -40,7 +43,9 @@ class WorkflowState {
}

async saveToBackend(submit=false) {
if (submit) this.job_hash = {}; // This will save a state where the submit call failed in between
const workflow = this.#serialize();
console.log('Saving workflow:', workflow);
try {
const csrfToken = document.querySelector('meta[name="csrf-token"]').content;

Expand All @@ -57,6 +62,12 @@ class WorkflowState {
const data = await response.json();
if (!response.ok) throw new Error(`Server error: ${response.status} message: ${data.message}`);
alert(data.message);
if (submit) {
this.job_hash = data.job_hash;
await this.#setJobDescription();
await this.#setupJobPoller();
this.saveToSession();
}
} catch (err) {
console.error('Error saving workflow:', err);
alert('Failed to save workflow. Check console for details.');
Expand All @@ -70,15 +81,15 @@ class WorkflowState {
let metadata = null;
const sessionTs = this.#parseTime(sessionMetadata?.saved_at);
const backendTs = this.#parseTime(backendMetadata?.saved_at);
if (sessionTs == null || (backendTs != null && sessionTs < backendTs)) {
if (sessionTs == null && backendTs == null) {
console.log('No saved workflow found in session or backend.');
return;
} else if (sessionTs < backendTs) {
metadata = backendMetadata;
console.log('Restoring workflow from backend metadata.');
} else if (backendTs == null){
} else {
metadata = sessionMetadata;
console.log('Restoring workflow from session metadata.');
} else {
console.log('No saved workflow found in session or backend.');
return;
}

try {
Expand All @@ -94,6 +105,9 @@ class WorkflowState {
this.pointer.zoomRef.value = metadata.zoom;
this.pointer.applyZoomCb();
}
this.job_hash = metadata.job_hash;
await this.#setJobDescription();
await this.#setupJobPoller();
console.info('Workflow restored correctly.');
} catch (err) {
console.error('Failed to apply stored workflow:', err);
Expand All @@ -113,6 +127,7 @@ class WorkflowState {
to: e.toBox.id
})),
zoom: this.pointer.zoomRef.value,
job_hash: this.job_hash,
saved_at: new Date().toISOString()
};
}
Expand Down Expand Up @@ -150,6 +165,145 @@ class WorkflowState {
return null;
}
}

async #setJobDescription() {
if(!this.job_hash || Object.keys(this.job_hash).length === 0)
return;

$.each(this.job_hash, function (launcherId, jobInfo) {
const $launcher = $(`#launcher_${launcherId}`);

if ($launcher.length && jobInfo) {
$launcher.attr({
"data-job-poller": "true",
"data-job-id": jobInfo.job_id,
"data-job-cluster": jobInfo.cluster_id
});
}
});
}

async #setupJobPoller() {
const self = this;
$('[data-job-poller="true"]').each((_index, ele) => {
this.poller.pollForJobInfo(ele);
});
}
}

// Polling class to update job status
class JobPoller {
constructor(projectId) {
this.projectId = projectId;
}

jobDetailsPath(cluster, jobId) {
const baseUrl = rootPath();
return `${baseUrl}/projects/${this.projectId}/jobs/${cluster}/${jobId}`;
}

stringToHtml(html) {
const template = document.createElement('template');
template.innerHTML = html.trim();
return template.content.firstChild;
}

async pollForJobInfo(element) {
const cluster = element.dataset['jobCluster'];
const jobId = element.dataset['jobId'];
const el = element.jquery ? element[0] : element;

if(cluster === undefined || jobId === undefined){ return; }

const url = this.jobDetailsPath(cluster, jobId);

fetch(url, { headers: { Accept: "text/vnd.turbo-stream.html" } })
.then(response => response.ok ? Promise.resolve(response) : Promise.reject(response.text()))
.then((r) => r.text())
.then((html) => {
const responseHtml = this.stringToHtml(html);
const jobState = responseHtml.dataset['jobNativeState'];

el.classList.remove('running', 'completed', 'failed', 'pending');
if( jobState === 'COMPLETED' ) {
el.classList.add('completed');
} else if ( jobState === 'FAILED' || jobState === 'CANCELLED' || jobState === 'TIMEOUT' ) {
el.classList.add('failed');
} else if ( jobState === 'RUNNING' || jobState === 'COMPLETING' ) {
el.classList.add('running');
} else if ( jobState === 'PENDING' || jobState === 'QUEUED' || jobState === 'SUSPENDED' ) {
el.classList.add('pending');
}

console.log(`Job ${jobId} on cluster ${cluster} native state: ${jobState}`);
return { jobState, html };
})
.then(({jobState, html}) => {
const endStates = ['COMPLETED', 'FAILED', 'CANCELLED', 'TIMEOUT', 'undefined'];
if(!endStates.includes(jobState)) {
setTimeout(() => this.pollForJobInfo(element), 30000);
} else {
element.dataset.jobPoller = "false";

// This needs not to be persistent with the session storage which we save to backend
const launcherId = el.id.replace('launcher_', '');
if (!launcherId) return;
const jobSessionKey = `job_info_html_${launcherId}`;
sessionStorage.setItem(jobSessionKey, html);

const infoBtn = document.getElementById(`job_info_${launcherId}`);
if (infoBtn) {
infoBtn.disabled = false;
infoBtn.classList.remove('disabled');

if (!infoBtn.dataset.listenerAttached) {
infoBtn.addEventListener('click', () => {
const html = sessionStorage.getItem(jobSessionKey);
this.showJobInfoOverlay(html);
});
infoBtn.dataset.listenerAttached = "true";
}
}
}
})
.catch((err) => {
console.log('Cannot not retrieve job info due to error:');
console.log(err);
});
}

showJobInfoOverlay(html) {
const existing = document.getElementById('job-info-overlay');
if (existing) existing.remove();

const overlay = document.createElement('div');
overlay.id = 'job-info-overlay';
const content = document.createElement('div');
content.className = 'job-info-content';

const template = this.stringToHtml(html).querySelector('template');
const fragment = template.content.cloneNode(true);
const collapseDiv = fragment.querySelector('.collapse');
if (collapseDiv) { // So save one extra click from user
collapseDiv.classList.remove('collapse');
collapseDiv.classList.add('show');
}
content.appendChild(fragment);

const closeBtn = document.createElement('button');
closeBtn.className = 'job-info-close';
closeBtn.textContent = '×';
closeBtn.addEventListener('click', () => overlay.remove());

overlay.addEventListener('click', (e) => {
if (e.target === overlay) overlay.remove();
});

content.prepend(closeBtn);
overlay.appendChild(content);
const stage = document.getElementById('workspace-wrapper');
stage.appendChild(overlay);
}
}

// Box represents a draggable launcher box
Expand Down Expand Up @@ -478,7 +632,6 @@ class DragController {
}

function createEdge(fromId, toId) {
console.log( `Creating edge from ${fromId} to ${toId}`);
if (fromId === toId) return;
if (!boxes.has(fromId) || !boxes.has(toId)) return;

Expand Down Expand Up @@ -651,7 +804,9 @@ class DragController {
deleteLauncherButton.addEventListener('click', deleteSelectedLauncher);
deleteEdgeButton.addEventListener('click', deleteSelectedEdge);

submitWorkflowButton.addEventListener('click', debounce(() => workflowState.saveToBackend(true), 300));
submitWorkflowButton.addEventListener('click', debounce(async () => {
await workflowState.saveToBackend(true);
}, 300));
resetWorkflowButton.addEventListener('click', debounce(e => workflowState.resetWorkflow(e), 300));
saveWorkflowButton.addEventListener('click', debounce(() => workflowState.saveToBackend(), 300));

Expand Down
11 changes: 8 additions & 3 deletions apps/dashboard/app/models/workflow.rb
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ def submit(attributes = {})
graph = Dag.new(attributes)
if graph.has_cycle
errors.add("Submit", "Specified edges form a cycle not directed-acyclic graph")
return false
return nil
end
dependency = graph.dependency
order = graph.order
Expand All @@ -138,20 +138,25 @@ def submit(attributes = {})
dependent_launchers = dependency[id] || []

begin
jobs = dependent_launchers.map { |id| job_id_hash[id] }.compact
jobs = dependent_launchers.map { |id| job_id_hash.dig(id, :job_id) }.compact
opts = submit_launcher_params(launcher, jobs).to_h.symbolize_keys
job_id = launcher.submit(opts)
if job_id.nil?
Rails.logger.warn("Launcher #{id} with opts #{opts} did not return a job ID.")
else
job_id_hash[id] = job_id
job_id_hash[id] = {
job_id: job_id,
cluster_id: opts[:auto_batch_clusters]
}
end
rescue => e
error_msg = "Launcher #{id} with opts #{opts} failed to submit. Error: #{e.class}: #{e.message}"
errors.add("Submit", error_msg)
Rails.logger.warn(error_msg)
end
end
return job_id_hash unless errors.any?
nil
end

def submit_launcher_params(launcher, dependent_jobs)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
id = "job_#{job.cluster}_#{job.id}"
-%>

<turbo-stream action="replace" target="<%= id %>" data-job-status="<%= job.status %>">
<turbo-stream action="replace" target="<%= id %>" data-job-status="<%= job.status %>" data-job-native-state="<%= job.native.dig(:state).to_s %>">
<template>
<%= render(partial: 'job_details_content', locals: { job: job, project: project }, :formats=>[:html]) %>
</template>
Expand Down
Loading