diff --git a/apps/dashboard/app/assets/stylesheets/projects.scss b/apps/dashboard/app/assets/stylesheets/projects.scss index f1c68188ee..f265e1d612 100644 --- a/apps/dashboard/app/assets/stylesheets/projects.scss +++ b/apps/dashboard/app/assets/stylesheets/projects.scss @@ -47,5 +47,6 @@ .launcher-button { color: white; width: 100%; - margin: 0.25rem; + margin: .2rem 0px; + text-wrap:nowrap; } \ No newline at end of file diff --git a/apps/dashboard/app/assets/stylesheets/workflows.scss b/apps/dashboard/app/assets/stylesheets/workflows.scss index c6d9c42d15..97b74eb606 100644 --- a/apps/dashboard/app/assets/stylesheets/workflows.scss +++ b/apps/dashboard/app/assets/stylesheets/workflows.scss @@ -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); + } +} \ No newline at end of file diff --git a/apps/dashboard/app/controllers/launchers_controller.rb b/apps/dashboard/app/controllers/launchers_controller.rb index 48db00975e..233ee2cba9 100644 --- a/apps/dashboard/app/controllers/launchers_controller.rb +++ b/apps/dashboard/app/controllers/launchers_controller.rb @@ -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 diff --git a/apps/dashboard/app/controllers/workflows_controller.rb b/apps/dashboard/app/controllers/workflows_controller.rb index 9e57afdd68..0575d24221 100644 --- a/apps/dashboard/app/controllers/workflows_controller.rb +++ b/apps/dashboard/app/controllers/workflows_controller.rb @@ -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 diff --git a/apps/dashboard/app/javascript/workflows.js b/apps/dashboard/app/javascript/workflows.js index 810022f7e1..e0805e1919 100644 --- a/apps/dashboard/app/javascript/workflows.js +++ b/apps/dashboard/app/javascript/workflows.js @@ -1,4 +1,5 @@ import { DAG } from './dag.js'; +import { rootPath } from './config.js'; /* * Helper Classes to support Drag and Drop UI @@ -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) { @@ -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; @@ -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.'); @@ -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 { @@ -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); @@ -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() }; } @@ -150,6 +165,147 @@ 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"; + } + + if (jobState !== 'undefined' && html) { + // 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 @@ -478,7 +634,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; @@ -651,7 +806,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)); diff --git a/apps/dashboard/app/models/workflow.rb b/apps/dashboard/app/models/workflow.rb index 021f700a0c..952c9841fd 100644 --- a/apps/dashboard/app/models/workflow.rb +++ b/apps/dashboard/app/models/workflow.rb @@ -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 @@ -138,13 +138,16 @@ 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}" @@ -152,6 +155,8 @@ def submit(attributes = {}) Rails.logger.warn(error_msg) end end + return job_id_hash unless errors.any? + nil end def submit_launcher_params(launcher, dependent_jobs) diff --git a/apps/dashboard/app/views/projects/_job_details.turbo_stream.erb b/apps/dashboard/app/views/projects/_job_details.turbo_stream.erb index 838f73339e..a9282e9f0b 100644 --- a/apps/dashboard/app/views/projects/_job_details.turbo_stream.erb +++ b/apps/dashboard/app/views/projects/_job_details.turbo_stream.erb @@ -3,7 +3,7 @@ id = "job_#{job.cluster}_#{job.id}" -%> - + diff --git a/apps/dashboard/app/views/projects/_launcher_buttons.html.erb b/apps/dashboard/app/views/projects/_launcher_buttons.html.erb index 39bcbf598a..2f48ba0549 100644 --- a/apps/dashboard/app/views/projects/_launcher_buttons.html.erb +++ b/apps/dashboard/app/views/projects/_launcher_buttons.html.erb @@ -4,6 +4,7 @@ edit_title = "#{t('dashboard.edit')} launcher #{launcher.title}" delete_title = "#{t('dashboard.delete')} launcher #{launcher.title}" remove_delete_button = @remove_delete_button + show_job_info_button = @show_job_info_button -%>
@@ -59,4 +60,17 @@ <%- end -%>
<% end %> + + <% if show_job_info_button %> +
+ +
+ <% end %> \ No newline at end of file diff --git a/apps/dashboard/app/views/workflows/show.html.erb b/apps/dashboard/app/views/workflows/show.html.erb index 821f18b8e3..cadf123f5a 100644 --- a/apps/dashboard/app/views/workflows/show.html.erb +++ b/apps/dashboard/app/views/workflows/show.html.erb @@ -22,7 +22,7 @@ -
+
diff --git a/apps/dashboard/config/locales/en.yml b/apps/dashboard/config/locales/en.yml index fdf33b5fc1..7e8008f0e8 100644 --- a/apps/dashboard/config/locales/en.yml +++ b/apps/dashboard/config/locales/en.yml @@ -152,6 +152,7 @@ en: files_shell: Open in Terminal files_shell_dropdown: Select Cluster to Open in Terminal import: Import + job_info: Job Info jobs_create_blank_project: Create a new project jobs_create_template_project: Create a new project from a template jobs_import_shared_project: Import a shared project