diff --git a/torc-dash/src/main.rs b/torc-dash/src/main.rs index 012ac0ad..f540f852 100644 --- a/torc-dash/src/main.rs +++ b/torc-dash/src/main.rs @@ -346,6 +346,7 @@ async fn main() -> Result<()> { post(cli_check_reinitialize_handler), ) .route("/api/cli/reset-status", post(cli_reset_status_handler)) + .route("/api/cli/execution-plan", post(cli_execution_plan_handler)) .route("/api/cli/run-stream", get(cli_run_stream_handler)) .route("/api/cli/read-file", post(cli_read_file_handler)) .route("/api/cli/plot-resources", post(cli_plot_resources_handler)) @@ -735,6 +736,26 @@ async fn cli_reset_status_handler( Json(result) } +/// Get execution plan for a workflow (includes scheduler allocations per stage) +async fn cli_execution_plan_handler( + State(state): State>, + Json(req): Json, +) -> impl IntoResponse { + let result = run_torc_command( + &state.torc_bin, + &[ + "-f", + "json", + "workflows", + "execution-plan", + &req.workflow_id, + ], + &state.api_url, + ) + .await; + Json(result) +} + /// Streaming workflow run handler using Server-Sent Events async fn cli_run_stream_handler( State(state): State>, diff --git a/torc-dash/static/js/api.js b/torc-dash/static/js/api.js index e84f7c6d..7c6820b5 100644 --- a/torc-dash/static/js/api.js +++ b/torc-dash/static/js/api.js @@ -333,6 +333,15 @@ class TorcAPI { return this.cliRequest('/api/cli/reset-status', { workflow_id: workflowId }); } + /** + * Get execution plan for a workflow (includes scheduler allocations per stage) + * @param {string} workflowId - Workflow ID + * @returns {object} CLI response with JSON execution plan in stdout + */ + async cliExecutionPlan(workflowId) { + return this.cliRequest('/api/cli/execution-plan', { workflow_id: workflowId }); + } + /** * Make a CLI command request */ diff --git a/torc-dash/static/js/app-modals.js b/torc-dash/static/js/app-modals.js index 730ee6cc..eda2ce45 100644 --- a/torc-dash/static/js/app-modals.js +++ b/torc-dash/static/js/app-modals.js @@ -327,93 +327,78 @@ Object.assign(TorcDashboard.prototype, { content.innerHTML = '
Loading execution plan...
'; try { - // Get jobs and dependencies - const [jobs, dependencies] = await Promise.all([ - api.listJobs(workflowId), - api.getJobsDependencies(workflowId), - ]); - - // Build dependency graph and compute stages - const stages = this.computeExecutionStages(jobs, dependencies); - content.innerHTML = this.renderExecutionPlan(stages, jobs); - } catch (error) { - content.innerHTML = `
Error loading execution plan: ${error.message}
`; - } - }, + // Get execution plan from CLI (includes scheduler allocations) + const result = await api.cliExecutionPlan(workflowId); - computeExecutionStages(jobs, dependencies) { - // Build a map of job dependencies - const blockedBy = {}; - dependencies.forEach(dep => { - if (!blockedBy[dep.job_id]) blockedBy[dep.job_id] = []; - blockedBy[dep.job_id].push(dep.depends_on_job_id); - }); - - // Create job map - const jobMap = {}; - jobs.forEach(j => jobMap[j.id] = j); - - // Compute stages using topological sort levels - const stages = []; - const completed = new Set(); - const remaining = new Set(jobs.map(j => j.id)); - - let stageNum = 1; - while (remaining.size > 0) { - const ready = []; - remaining.forEach(jobId => { - const deps = blockedBy[jobId] || []; - if (deps.every(d => completed.has(d))) { - ready.push(jobId); - } - }); - - if (ready.length === 0 && remaining.size > 0) { - // Circular dependency or error - break to avoid infinite loop - break; + if (!result.success) { + content.innerHTML = `
Error: ${this.escapeHtml(result.stderr || result.stdout || 'Unknown error')}
`; + return; } - stages.push({ - stageNumber: stageNum++, - jobs: ready.map(id => jobMap[id]), - }); - - ready.forEach(id => { - completed.add(id); - remaining.delete(id); - }); + // Parse the JSON output + let plan; + try { + plan = JSON.parse(result.stdout); + } catch (parseError) { + content.innerHTML = `
Invalid JSON response from server: ${this.escapeHtml(parseError.message)}
`; + return; + } + content.innerHTML = this.renderExecutionPlan(plan); + } catch (error) { + content.innerHTML = `
Error loading execution plan: ${this.escapeHtml(error.message)}
`; } - - return stages; }, - renderExecutionPlan(stages, jobs) { - if (stages.length === 0) { + renderExecutionPlan(plan) { + if (!plan.stages || plan.stages.length === 0) { return '
No execution stages computed
'; } return `
- Total Stages: ${stages.length} | - Total Jobs: ${jobs.length} + Workflow: ${this.escapeHtml(plan.workflow_name || 'Unnamed')} | + Total Stages: ${plan.total_stages} | + Total Jobs: ${plan.total_jobs}
- ${stages.map(stage => ` + ${plan.stages.map(stage => `
-
${stage.stageNumber}
-
Stage ${stage.stageNumber}
+
${stage.stage_number}
+
${this.escapeHtml(stage.trigger)}
-
Jobs Ready (${stage.jobs.length})
+
Jobs Becoming Ready (${stage.jobs_becoming_ready.length})
    - ${stage.jobs.slice(0, 10).map(job => ` -
  • ${this.escapeHtml(job.name || job.id)}
  • + ${stage.jobs_becoming_ready.slice(0, 10).map(jobName => ` +
  • ${this.escapeHtml(jobName)}
  • `).join('')} - ${stage.jobs.length > 10 ? `
  • ... and ${stage.jobs.length - 10} more
  • ` : ''} + ${stage.jobs_becoming_ready.length > 10 ? `
  • ... and ${stage.jobs_becoming_ready.length - 10} more
  • ` : ''}
+ ${this.renderSchedulerAllocations(stage.scheduler_allocations)}
`).join('')} `; }, + + renderSchedulerAllocations(allocations) { + if (!allocations || allocations.length === 0) { + return ''; + } + + return ` +
+
Scheduler Allocations
+ ${allocations.map(alloc => ` +
+
Scheduler: ${this.escapeHtml(alloc.scheduler)} (${this.escapeHtml(alloc.scheduler_type)})
+
Allocations: ${alloc.num_allocations}
+ ${alloc.job_names && alloc.job_names.length > 0 ? ` +
Jobs: ${alloc.job_names.slice(0, 5).map(n => this.escapeHtml(n)).join(', ')}${alloc.job_names.length > 5 ? ` ... and ${alloc.job_names.length - 5} more` : ''}
+ ` : ''} +
+ `).join('')} +
+ `; + }, }); diff --git a/torc-dash/static/js/app-wizard.js b/torc-dash/static/js/app-wizard.js index 20f73919..22b49959 100644 --- a/torc-dash/static/js/app-wizard.js +++ b/torc-dash/static/js/app-wizard.js @@ -24,7 +24,8 @@ Object.assign(TorcDashboard.prototype, { this.resourcePresets = { 'small': { name: 'Small', num_cpus: 1, memory: '1g', num_gpus: 0 }, - 'medium': { name: 'Medium', num_cpus: 8, memory: '50g', num_gpus: 0 }, + 'medium': { name: 'Medium', num_cpus: 4, memory: '10g', num_gpus: 0 }, + 'large': { name: 'Large', num_cpus: 8, memory: '50g', num_gpus: 0 }, 'gpu': { name: 'GPU', num_cpus: 1, memory: '10g', num_gpus: 1 }, 'custom': { name: 'Custom', num_cpus: 1, memory: '1g', num_gpus: 0 } }; @@ -373,16 +374,22 @@ Object.assign(TorcDashboard.prototype, { wizardAddSchedulerFromJob(jobId) { const schedulerId = ++this.wizardSchedulerIdCounter; - const schedulerName = `scheduler-${schedulerId}`; + const defaultName = `scheduler-${schedulerId}`; + const schedulerName = prompt('Enter a name for the new scheduler:', defaultName); + if (!schedulerName || !schedulerName.trim()) { + // User cancelled or entered only whitespace + this.wizardSchedulerIdCounter--; + return; + } const scheduler = { - id: schedulerId, name: schedulerName, account: '', nodes: 1, walltime: '01:00:00', + id: schedulerId, name: schedulerName.trim(), account: '', nodes: 1, walltime: '01:00:00', partition: '', qos: '', gres: '', mem: '', tmp: '', extra: '' }; this.wizardSchedulers.push(scheduler); const job = this.wizardJobs.find(j => j.id === jobId); - if (job) job.scheduler = schedulerName; + if (job) job.scheduler = schedulerName.trim(); this.wizardRenderJobs(); - this.showToast(`Scheduler "${schedulerName}" created. Configure it in step 3.`, 'info'); + this.showToast(`Scheduler "${schedulerName.trim()}" created. Configure it in step 3.`, 'info'); }, wizardToggleScheduler(schedulerId) {