From 94a0454d3fda0b67c25bb42f71b7015a1ae9ec25 Mon Sep 17 00:00:00 2001 From: Deepak Ramalingam Date: Mon, 30 Jun 2025 01:30:53 -0700 Subject: [PATCH 1/6] Initial instances page with error handling --- .run/MaestroApp.run.xml | 10 + .../src/main/resources/static/index.html | 229 ++++++++++++++++++ 2 files changed, 239 insertions(+) create mode 100644 .run/MaestroApp.run.xml create mode 100644 maestro-server/src/main/resources/static/index.html diff --git a/.run/MaestroApp.run.xml b/.run/MaestroApp.run.xml new file mode 100644 index 00000000..0220e19c --- /dev/null +++ b/.run/MaestroApp.run.xml @@ -0,0 +1,10 @@ + + + + + \ No newline at end of file diff --git a/maestro-server/src/main/resources/static/index.html b/maestro-server/src/main/resources/static/index.html new file mode 100644 index 00000000..b4776dc4 --- /dev/null +++ b/maestro-server/src/main/resources/static/index.html @@ -0,0 +1,229 @@ + + + + + Maestro + + + + + + +
+

Workflow Instances

+
+ + + + + + + + + + + + + +
Instance IDStatusCreate TimeStart TimeEnd TimeUUID
+
+ + + + \ No newline at end of file From f193d91fa28faa4f1b904ec89402bb09f4a705f4 Mon Sep 17 00:00:00 2001 From: Deepak Ramalingam Date: Mon, 30 Jun 2025 01:56:13 -0700 Subject: [PATCH 2/6] Use hash router --- .../src/main/resources/static/index.html | 55 ++++++++++++++----- 1 file changed, 40 insertions(+), 15 deletions(-) diff --git a/maestro-server/src/main/resources/static/index.html b/maestro-server/src/main/resources/static/index.html index b4776dc4..2e851108 100644 --- a/maestro-server/src/main/resources/static/index.html +++ b/maestro-server/src/main/resources/static/index.html @@ -115,8 +115,22 @@

Workflow Instances

- \ No newline at end of file + From acf663a1b8eb131e4febdc15acf9baae6b9bd220 Mon Sep 17 00:00:00 2001 From: Deepak Ramalingam Date: Mon, 30 Jun 2025 02:07:19 -0700 Subject: [PATCH 3/6] Hide content if workflow does not exist --- .../src/main/resources/static/index.html | 75 ++++++++++++++----- 1 file changed, 58 insertions(+), 17 deletions(-) diff --git a/maestro-server/src/main/resources/static/index.html b/maestro-server/src/main/resources/static/index.html index 2e851108..403d14d8 100644 --- a/maestro-server/src/main/resources/static/index.html +++ b/maestro-server/src/main/resources/static/index.html @@ -96,9 +96,9 @@
-

Workflow Instances

+

Workflow Instances

- + @@ -131,6 +131,11 @@

Workflow Instances

// Set workflowId based on URL hash const workflowId = hashParts[1]; + // Update page title with workflow ID + if (workflowId) { + document.getElementById('pageTitle').textContent = workflowId; + } + const errors = new Set(); function showError(message, errorId = Date.now()) { @@ -233,22 +238,58 @@

Workflow Instances

}); } - // Check if workflowId is blank and show error if it is - if (isWorkflowIdBlank) { - // Show error message - showError('Workflow ID is blank. Please provide a valid workflow ID in the URL.'); - - // Disable the start button - document.querySelector('button').disabled = true; - document.querySelector('button').style.backgroundColor = '#cccccc'; - document.querySelector('button').style.cursor = 'not-allowed'; - } else { - // Initial fetch - fetchInstances(); - - // Refresh every X seconds - setInterval(fetchInstances, 2000); + function checkWorkflowExists() { + fetch(`/api/v3/workflows/${workflowId}/versions/latest`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json' + } + }) + .then(response => { + if (!response.ok) { + return response.json().then(error => { + if (error.status === 'NOT_FOUND') { + // Workflow doesn't exist + showError(`Workflow [${workflowId}] has not been created yet or has been deleted.`); + + // Hide the start button and table + document.getElementById('startButton').style.display = 'none'; + document.getElementById('workflowTable').style.display = 'none'; + } else { + throw new Error(error.message || `${response.status}: ${JSON.stringify(error)}`); + } + }); + } else { + // Workflow exists, fetch instances + fetchInstances(); + + // Refresh every X seconds + setInterval(fetchInstances, 2000); + } + }) + .catch(error => { + console.error('Error:', error); + showError('Error checking workflow: ' + error.message); + }); } + + // Wait for the DOM to be fully loaded before manipulating elements + document.addEventListener('DOMContentLoaded', function() { + // Check if workflowId is blank and show error if it is + if (isWorkflowIdBlank) { + // Show error message + showError('Workflow ID is blank. Please provide a valid workflow ID in the URL.'); + + // Disable the start button + document.getElementById('startButton').disabled = true; + document.getElementById('startButton').style.backgroundColor = '#cccccc'; + document.getElementById('startButton').style.cursor = 'not-allowed'; + } else { + // Check if workflow exists before fetching instances + checkWorkflowExists(); + } + }); From 25b9fe89e832f03421caf217ebf9bea22df33f00 Mon Sep 17 00:00:00 2001 From: Deepak Ramalingam Date: Mon, 30 Jun 2025 23:56:20 -0700 Subject: [PATCH 4/6] Add workflow runs, step instances, and step attempts --- .../src/main/resources/static/index.html | 552 +++++++++++++++++- 1 file changed, 548 insertions(+), 4 deletions(-) diff --git a/maestro-server/src/main/resources/static/index.html b/maestro-server/src/main/resources/static/index.html index 403d14d8..bdd1f3ef 100644 --- a/maestro-server/src/main/resources/static/index.html +++ b/maestro-server/src/main/resources/static/index.html @@ -51,6 +51,18 @@ color: #d32f2f; font-weight: 500; } + .status-in_progress { + color: #ff9800; + font-weight: 500; + } + .status-stopped { + color: #9e9e9e; + font-weight: 500; + } + .status-timed_out { + color: #ff5722; + font-weight: 500; + } button { padding: 12px 24px; margin: 10px 0; @@ -92,6 +104,103 @@ background-color: rgba(198, 40, 40, 0.1); border-radius: 4px; } + .instance-row { + cursor: pointer; + } + .instance-details { + display: none; + padding: 20px; + background-color: #f9f9f9; + border-radius: 4px; + margin-top: 10px; + margin-bottom: 10px; + } + .run-selector { + margin-bottom: 15px; + } + .dag-container { + width: 100%; + height: 400px; + border: 1px solid #ddd; + margin-top: 15px; + position: relative; + overflow: auto; + } + .step-node { + position: absolute; + width: 150px; + padding: 10px; + border-radius: 4px; + background-color: #e3f2fd; + border: 1px solid #bbdefb; + box-shadow: 0 1px 3px rgba(0,0,0,0.1); + text-align: center; + cursor: pointer; + z-index: 1; + } + .step-node.selected { + border: 2px solid #1976d2; + } + .step-node.status-succeeded { + background-color: #e8f5e9; + border-color: #c8e6c9; + } + .step-node.status-failed { + background-color: #ffebee; + border-color: #ffcdd2; + } + .step-node.status-in_progress { + background-color: #fff8e1; + border-color: #ffecb3; + } + .step-node.status-created, .step-node.status-initialized { + background-color: #e3f2fd; + border-color: #bbdefb; + } + .step-node.status-stopped { + background-color: #f5f5f5; + border-color: #e0e0e0; + } + .step-node.status-timed_out { + background-color: #fbe9e7; + border-color: #ffccbc; + } + .step-edge { + position: absolute; + height: 2px; + background-color: #90caf9; + z-index: 0; + transform-origin: 0 0; + } + .step-details { + margin-top: 15px; + padding: 15px; + border: 1px solid #ddd; + border-radius: 4px; + background-color: white; + } + .attempt-selector { + margin-bottom: 10px; + } + .step-info { + margin-top: 10px; + } + .step-info-item { + margin-bottom: 5px; + } + .step-info-label { + font-weight: bold; + } + .dropdown { + padding: 8px; + border-radius: 4px; + border: 1px solid #ddd; + background-color: white; + font-size: 14px; + } + .clickable { + cursor: pointer; + } @@ -112,6 +221,35 @@

Workflow Instances

+ + +
+

Workflow Instance:

+ + +
+ + +
+ + +

Workflow Steps

+
+ + + +
+ diff --git a/maestro-server/src/main/resources/static/js/api.js b/maestro-server/src/main/resources/static/js/api.js new file mode 100644 index 00000000..23713aa2 --- /dev/null +++ b/maestro-server/src/main/resources/static/js/api.js @@ -0,0 +1,176 @@ +/** + * API functions for the Maestro UI + */ +import { handleResponse } from './utils.js'; + +/** + * Start a new workflow instance + * @param {string} workflowId - The ID of the workflow to start + * @returns {Promise} - The created workflow instance + */ +async function startWorkflowInstance(workflowId) { + try { + const response = await fetch(`/api/v3/workflows/${workflowId}/versions/latest/actions/start`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'user': 'tester' + }, + body: JSON.stringify({ + initiator: { + type: 'manual' + } + }) + }); + return await handleResponse(response); + } catch (error) { + console.error('Error starting workflow:', error); + throw error; + } +} + +/** + * Fetch workflow instances + * @param {string} workflowId - The ID of the workflow + * @param {number} limit - The maximum number of instances to fetch + * @returns {Promise} - The workflow instances + */ +async function fetchWorkflowInstances(workflowId, limit = 100) { + try { + const response = await fetch(`/api/v3/workflows/${workflowId}/instances?first=${limit}`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json' + } + }); + return await handleResponse(response); + } catch (error) { + console.error('Error fetching workflow instances:', error); + throw error; + } +} + +/** + * Fetch workflow instance details + * @param {string} workflowId - The ID of the workflow + * @param {string} instanceId - The ID of the instance + * @returns {Promise} - The workflow instance details + */ +async function fetchWorkflowInstanceDetails(workflowId, instanceId) { + try { + const response = await fetch(`/api/v3/workflows/${workflowId}/instances/${instanceId}`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json' + } + }); + return await handleResponse(response); + } catch (error) { + console.error('Error fetching instance details:', error); + throw error; + } +} + +/** + * Fetch workflow run details + * @param {string} workflowId - The ID of the workflow + * @param {string} instanceId - The ID of the instance + * @param {string} runId - The ID of the run + * @returns {Promise} - The workflow run details + */ +async function fetchWorkflowRunDetails(workflowId, instanceId, runId) { + try { + const response = await fetch(`/api/v3/workflows/${workflowId}/instances/${instanceId}/runs/${runId}`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json' + } + }); + return await handleResponse(response); + } catch (error) { + console.error('Error fetching run details:', error); + throw error; + } +} + +/** + * Fetch step details + * @param {string} workflowId - The ID of the workflow + * @param {string} instanceId - The ID of the instance + * @param {string} stepId - The ID of the step + * @returns {Promise} - The step details + */ +async function fetchStepDetails(workflowId, instanceId, stepId) { + try { + const response = await fetch(`/api/v3/workflows/${workflowId}/instances/${instanceId}/steps/${stepId}`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json' + } + }); + return await handleResponse(response); + } catch (error) { + console.error('Error fetching step details:', error); + throw error; + } +} + +/** + * Fetch step attempt details + * @param {string} workflowId - The ID of the workflow + * @param {string} instanceId - The ID of the instance + * @param {string} runId - The ID of the run + * @param {string} stepId - The ID of the step + * @param {string} attemptId - The ID of the attempt + * @returns {Promise} - The step attempt details + */ +async function fetchStepAttemptDetails(workflowId, instanceId, runId, stepId, attemptId) { + try { + const response = await fetch(`/api/v3/workflows/${workflowId}/instances/${instanceId}/runs/${runId}/steps/${stepId}/attempts/${attemptId}`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json' + } + }); + return await handleResponse(response); + } catch (error) { + console.error('Error fetching step attempt details:', error); + throw error; + } +} + +/** + * Check if a workflow exists + * @param {string} workflowId - The ID of the workflow + * @returns {Promise} - The workflow details if it exists + */ +async function checkWorkflowExists(workflowId) { + try { + const response = await fetch(`/api/v3/workflows/${workflowId}/versions/latest`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json' + } + }); + return await handleResponse(response); + } catch (error) { + console.error('Error checking workflow:', error); + throw error; + } +} + +export { + startWorkflowInstance, + fetchWorkflowInstances, + fetchWorkflowInstanceDetails, + fetchWorkflowRunDetails, + fetchStepDetails, + fetchStepAttemptDetails, + checkWorkflowExists +}; \ No newline at end of file diff --git a/maestro-server/src/main/resources/static/js/main.js b/maestro-server/src/main/resources/static/js/main.js new file mode 100644 index 00000000..06fd5225 --- /dev/null +++ b/maestro-server/src/main/resources/static/js/main.js @@ -0,0 +1,213 @@ +/** + * Main application logic for the Maestro UI + */ +import * as api from './api.js'; +import * as ui from './ui.js'; + +// Global state +let state = { + workflowId: null, + currentInstanceId: null, + currentRunId: null, + currentStepId: null, + currentAttemptId: null, + currentInstance: null, + currentRun: null +}; + +/** + * Initializes the application + */ +async function init() { + // Parse the URL hash to get the workflow ID + parseUrlHash(); + + // Check if workflowId is blank and show error if it is + if (!state.workflowId) { + ui.showError('Workflow ID is blank. Please provide a valid workflow ID in the URL.'); + disableStartButton(); + return; + } + + // Update page title with workflow ID + document.getElementById('pageTitle').textContent = state.workflowId; + + // Set up event listeners + document.getElementById('startButton').addEventListener('click', startWorkflow); + + // Check if workflow exists + try { + await api.checkWorkflowExists(state.workflowId); + + // Workflow exists, fetch instances + await fetchAndUpdateInstances(); + + // Set up periodic refresh + setInterval(fetchAndUpdateInstances, 2000); + } catch (error) { + if (error.message.includes('NOT_FOUND')) { + ui.showError(`Workflow [${state.workflowId}] has not been created yet or has been deleted.`); + disableStartButton(); + document.getElementById('workflowTable').style.display = 'none'; + } else { + ui.showError('Error checking workflow: ' + error.message); + } + } +} + +/** + * Parses the URL hash to extract the workflow ID + */ +function parseUrlHash() { + // Check if we're at the root path and redirect to /#/workflows/{workflowId}/instances if needed + if (!window.location.hash) { + // We're at the root path, redirect to /#/workflows/sample-dag-test-1/instances + window.location.href = '/#/workflows/sample-dag-test-1/instances'; + return; + } + + // Get workflowId from URL hash: /#/workflows/{workflowId}/instances + const hashParts = window.location.hash.substring(1).split('/').filter(part => part.length > 0); + + // Check if the URL has the correct format but workflowId is blank + const hasWorkflowsPath = hashParts.length >= 1 && hashParts[0] === 'workflows'; + const isWorkflowIdBlank = hasWorkflowsPath && (hashParts.length === 1 || hashParts[1] === ''); + + // Set workflowId based on URL hash + state.workflowId = isWorkflowIdBlank ? null : hashParts[1]; +} + +/** + * Disables the start button + */ +function disableStartButton() { + const startButton = document.getElementById('startButton'); + startButton.disabled = true; + startButton.style.backgroundColor = '#cccccc'; + startButton.style.cursor = 'not-allowed'; +} + +/** + * Starts a new workflow instance + */ +async function startWorkflow() { + try { + await api.startWorkflowInstance(state.workflowId); + await fetchAndUpdateInstances(); + } catch (error) { + ui.showError('Error starting workflow: ' + error.message); + } +} + +/** + * Fetches workflow instances and updates the UI + */ +async function fetchAndUpdateInstances() { + try { + const data = await api.fetchWorkflowInstances(state.workflowId); + ui.updateWorkflowInstancesTable(data, showInstanceDetails); + } catch (error) { + ui.showError('Error fetching data: ' + error.message); + } +} + +/** + * Shows instance details + * @param {string} instanceId - The ID of the instance to show + */ +async function showInstanceDetails(instanceId) { + state.currentInstanceId = instanceId; + ui.showInstanceDetails(instanceId); + + try { + // Fetch the instance details + const instance = await api.fetchWorkflowInstanceDetails(state.workflowId, instanceId); + state.currentInstance = instance; + + // Populate run selector and get current run ID + state.currentRunId = ui.populateRunSelector(instance, handleRunChange); + + // Fetch the run details + await fetchRunDetails(state.currentRunId); + } catch (error) { + ui.showError('Error fetching instance details: ' + error.message); + } +} + +/** + * Handles run selection change + * @param {string} runId - The ID of the selected run + */ +async function handleRunChange(runId) { + state.currentRunId = runId; + await fetchRunDetails(runId); +} + +/** + * Fetches run details and updates the UI + * @param {string} runId - The ID of the run + */ +async function fetchRunDetails(runId) { + try { + const run = await api.fetchWorkflowRunDetails(state.workflowId, state.currentInstanceId, runId); + state.currentRun = run; + + // Visualize the DAG + await ui.visualizeDAG(run, handleStepClick); + } catch (error) { + ui.showError('Error fetching run details: ' + error.message); + } +} + +/** + * Handles step click in the DAG + * @param {string} stepId - The ID of the clicked step + * @param {boolean} fetchOnly - If true, only fetch the step details without updating UI + * @returns {Promise} - The step details + */ +async function handleStepClick(stepId, fetchOnly = false) { + try { + state.currentStepId = stepId; + + // Fetch step details + const step = await api.fetchStepDetails(state.workflowId, state.currentInstanceId, stepId); + + if (!fetchOnly) { + // Show step details in UI + state.currentAttemptId = ui.showStepDetails(step, handleAttemptChange); + } + + return step; + } catch (error) { + ui.showError('Error fetching step details: ' + error.message); + throw error; + } +} + +/** + * Handles attempt selection change + * @param {string} stepId - The ID of the step + * @param {string} attemptId - The ID of the selected attempt + */ +async function handleAttemptChange(stepId, attemptId) { + try { + state.currentAttemptId = attemptId; + + // Fetch step attempt details + const attempt = await api.fetchStepAttemptDetails( + state.workflowId, + state.currentInstanceId, + state.currentRunId, + stepId, + attemptId + ); + + // Display step attempt info + ui.displayStepInfo(attempt); + } catch (error) { + ui.showError('Error fetching step attempt: ' + error.message); + } +} + +// Initialize the application when the DOM is fully loaded +document.addEventListener('DOMContentLoaded', init); \ No newline at end of file diff --git a/maestro-server/src/main/resources/static/js/ui.js b/maestro-server/src/main/resources/static/js/ui.js new file mode 100644 index 00000000..ed740d8b --- /dev/null +++ b/maestro-server/src/main/resources/static/js/ui.js @@ -0,0 +1,349 @@ +/** + * UI functions for the Maestro UI + */ +import { formatDate, calculateDuration } from './utils.js'; + +// Set of error IDs to prevent duplicate errors +const errors = new Set(); + +/** + * Shows an error message in the UI + * @param {string} message - The error message to display + * @param {string|number} errorId - A unique ID for the error (defaults to current timestamp) + */ +function showError(message, errorId = Date.now()) { + const errorsContainer = document.getElementById('errorsContainer'); + const errorDiv = document.createElement('div'); + errorDiv.className = 'error-message'; + errorDiv.id = `error-${errorId}`; + + const messageSpan = document.createElement('span'); + messageSpan.textContent = message; + + const dismissButton = document.createElement('button'); + dismissButton.className = 'error-dismiss'; + dismissButton.innerHTML = '×'; + dismissButton.onclick = () => { + errorDiv.remove(); + errors.delete(errorId); + }; + + errorDiv.appendChild(messageSpan); + errorDiv.appendChild(dismissButton); + + if (!errors.has(errorId)) { + errors.add(errorId); + errorsContainer.appendChild(errorDiv); + } +} + +/** + * Updates the workflow instances table with the provided data + * @param {Object} data - The workflow instances data + * @param {Function} onInstanceClick - Callback function when an instance is clicked + */ +function updateWorkflowInstancesTable(data, onInstanceClick) { + const tbody = document.getElementById('result'); + tbody.innerHTML = data.elements.map(instance => ` + + ${instance.workflow_instance_id} + ${instance.status} + ${formatDate(instance.create_time)} + ${formatDate(instance.start_time)} + ${formatDate(instance.end_time)} + ${instance.workflow_uuid} + + `).join(''); + + // Add click handlers to instance rows + document.querySelectorAll('.instance-row').forEach(row => { + row.addEventListener('click', function() { + const instanceId = this.getAttribute('data-instance-id'); + onInstanceClick(instanceId); + }); + }); +} + +/** + * Shows the instance details panel + * @param {string} instanceId - The ID of the instance to show + */ +function showInstanceDetails(instanceId) { + document.getElementById('instanceIdDisplay').textContent = instanceId; + document.getElementById('instanceDetailsContainer').style.display = 'block'; +} + +/** + * Populates the run selector dropdown + * @param {Object} instance - The workflow instance + * @param {Function} onRunChange - Callback function when a run is selected + */ +function populateRunSelector(instance, onRunChange) { + const runSelector = document.getElementById('runSelector'); + runSelector.innerHTML = ''; + + // Add the latest run + const option = document.createElement('option'); + option.value = instance.workflow_run_id; + option.textContent = `Run ${instance.workflow_run_id} (Latest)`; + runSelector.appendChild(option); + + // Add event listener to run selector + runSelector.addEventListener('change', function() { + onRunChange(this.value); + }); + + return instance.workflow_run_id; +} + +/** + * Visualizes the DAG (Directed Acyclic Graph) of a workflow run + * @param {Object} run - The workflow run data + * @param {Function} onStepClick - Callback function when a step is clicked + */ +async function visualizeDAG(run, onStepClick) { + const dagContainer = document.getElementById('dagContainer'); + dagContainer.innerHTML = ''; + + // Hide step details + document.getElementById('stepDetailsContainer').style.display = 'none'; + + // Check if we have a runtime DAG + if (!run.runtime_dag) { + dagContainer.innerHTML = '

No DAG information available for this run.

'; + return; + } + + // Get the steps from the runtime workflow + const steps = run.runtime_workflow.steps; + if (!steps || steps.length === 0) { + dagContainer.innerHTML = '

No steps available for this run.

'; + return; + } + + // Create nodes for each step + const nodes = {}; + const horizontalSpacing = 200; + const verticalSpacing = 100; + + // First, create a map of step ID to step + const stepsMap = {}; + steps.forEach(stepObj => { + const step = stepObj.step; + stepsMap[step.id] = step; + }); + + // Create a map of step ID to level (depth in the DAG) + const levels = {}; + const visited = new Set(); + + // Find root nodes (steps with no predecessors) + const rootNodes = []; + for (const stepId in run.runtime_dag) { + const transition = run.runtime_dag[stepId]; + if (!transition.predecessors || transition.predecessors.length === 0) { + rootNodes.push(stepId); + } + } + + // Assign levels to nodes using BFS + const queue = rootNodes.map(id => ({ id, level: 0 })); + while (queue.length > 0) { + const { id, level } = queue.shift(); + if (visited.has(id)) continue; + + visited.add(id); + levels[id] = level; + + // Add successors to queue + const transition = run.runtime_dag[id]; + if (transition && transition.successors) { + for (const successorId in transition.successors) { + queue.push({ id: successorId, level: level + 1 }); + } + } + } + + // Count nodes at each level + const nodesAtLevel = {}; + for (const id in levels) { + const level = levels[id]; + nodesAtLevel[level] = (nodesAtLevel[level] || 0) + 1; + } + + // Calculate positions for each node + const positions = {}; + for (const id in levels) { + const level = levels[id]; + const levelNodes = Object.entries(levels) + .filter(([_, l]) => l === level) + .map(([id]) => id); + + const index = levelNodes.indexOf(id); + const x = level * horizontalSpacing + 50; + const y = (index + 0.5) * verticalSpacing + 50; + + positions[id] = { x, y }; + } + + // Create nodes + for (const stepId in stepsMap) { + const step = stepsMap[stepId]; + const position = positions[stepId] || { x: 50, y: 50 }; + + // Get step status from step details + const stepDetails = await onStepClick(stepId, true); + let status = stepDetails.runtime_state.status; + + // Create node element + const node = document.createElement('div'); + node.className = `step-node status-${status.toLowerCase()}`; + node.id = `step-${stepId}`; + node.setAttribute('data-step-id', stepId); + node.style.left = `${position.x}px`; + node.style.top = `${position.y}px`; + node.innerHTML = ` +
${step.id}
+
${status}
+ `; + + // Add click handler + node.addEventListener('click', function() { + // Deselect all nodes + document.querySelectorAll('.step-node').forEach(n => n.classList.remove('selected')); + + // Select this node + this.classList.add('selected'); + + // Show step details + onStepClick(stepId); + }); + + dagContainer.appendChild(node); + nodes[stepId] = node; + } + + // Create edges + for (const stepId in run.runtime_dag) { + const transition = run.runtime_dag[stepId]; + if (transition && transition.successors) { + for (const successorId in transition.successors) { + createEdge(stepId, successorId, nodes); + } + } + } +} + +/** + * Creates an edge between two nodes in the DAG + * @param {string} fromId - The ID of the source node + * @param {string} toId - The ID of the target node + * @param {Object} nodes - Map of node IDs to node elements + */ +function createEdge(fromId, toId, nodes) { + const fromNode = nodes[fromId]; + const toNode = nodes[toId]; + + if (!fromNode || !toNode) return; + + const fromRect = fromNode.getBoundingClientRect(); + const toRect = toNode.getBoundingClientRect(); + + const dagContainer = document.getElementById('dagContainer'); + const containerRect = dagContainer.getBoundingClientRect(); + + const fromX = parseInt(fromNode.style.left) + 150; // Right side of from node + const fromY = parseInt(fromNode.style.top) + 30; // Middle of from node + const toX = parseInt(toNode.style.left); // Left side of to node + const toY = parseInt(toNode.style.top) + 30; // Middle of to node + + const length = Math.sqrt(Math.pow(toX - fromX, 2) + Math.pow(toY - fromY, 2)); + const angle = Math.atan2(toY - fromY, toX - fromX); + + const edge = document.createElement('div'); + edge.className = 'step-edge'; + edge.style.width = `${length}px`; + edge.style.left = `${fromX}px`; + edge.style.top = `${fromY}px`; + edge.style.transform = `rotate(${angle}rad)`; + + dagContainer.appendChild(edge); +} + +/** + * Populates the attempt selector dropdown + * @param {Object} step - The step data + * @param {Function} onAttemptChange - Callback function when an attempt is selected + */ +function populateAttemptSelector(step, onAttemptChange) { + const attemptSelector = document.getElementById('attemptSelector'); + attemptSelector.innerHTML = ''; + + // Add the latest attempt + const option = document.createElement('option'); + option.value = step.step_attempt_id; + option.textContent = `Attempt ${step.step_attempt_id} (Latest)`; + attemptSelector.appendChild(option); + + // Add event listener to attempt selector + attemptSelector.addEventListener('change', function() { + onAttemptChange(step.step_id, this.value); + }); + + return step.step_attempt_id; +} + +/** + * Displays step information in the UI + * @param {Object} step - The step data + */ +function displayStepInfo(step) { + const stepInfo = document.getElementById('stepInfo'); + stepInfo.innerHTML = ''; + + // Display step properties + const properties = [ + { label: 'ID', value: step.step_id }, + { label: 'Status', value: step.runtime_state ? step.runtime_state.status : 'N/A', class: step.runtime_state ? `status-${step.runtime_state.status.toLowerCase()}` : '' }, + { label: 'Type', value: step?.definition?.step?.type || 'N/A' }, + { label: 'Start Time', value: formatDate(step.runtime_state ? step.runtime_state.start_time : null) }, + { label: 'End Time', value: formatDate(step.runtime_state ? step.runtime_state.end_time : null) }, + { label: 'Duration', value: calculateDuration(step.runtime_state ? step.runtime_state.start_time : null, step.runtime_state ? step.runtime_state.end_time : null) } + ]; + + properties.forEach(prop => { + const item = document.createElement('div'); + item.className = 'step-info-item'; + item.innerHTML = ` + ${prop.label}: + ${prop.value} + `; + stepInfo.appendChild(item); + }); +} + +/** + * Shows step details in the UI + * @param {Object} step - The step data + * @param {Function} onAttemptChange - Callback function when an attempt is selected + */ +function showStepDetails(step, onAttemptChange) { + document.getElementById('stepIdDisplay').textContent = step.id; + document.getElementById('stepDetailsContainer').style.display = 'block'; + + const attemptId = populateAttemptSelector(step, onAttemptChange); + displayStepInfo(step); + + return attemptId; +} + +export { + showError, + updateWorkflowInstancesTable, + showInstanceDetails, + populateRunSelector, + visualizeDAG, + populateAttemptSelector, + displayStepInfo, + showStepDetails +}; \ No newline at end of file diff --git a/maestro-server/src/main/resources/static/js/utils.js b/maestro-server/src/main/resources/static/js/utils.js new file mode 100644 index 00000000..e2d5db66 --- /dev/null +++ b/maestro-server/src/main/resources/static/js/utils.js @@ -0,0 +1,86 @@ +/** + * Utility functions for the Maestro UI + */ + +/** + * Formats a timestamp into a human-readable date string + * @param {number} epoch - The timestamp to format + * @returns {string} - The formatted date string + */ +function formatDate(epoch) { + if (epoch === undefined) { + return '-'; + } + + if (epoch.toString().length === 10) { + epoch *= 1000; + } + + const date = new Date(epoch); + const pad = (n) => n.toString().padStart(2, '0'); + + const year = date.getFullYear(); + const month = pad(date.getMonth() + 1); + const day = pad(date.getDate()); + const hours = pad(date.getHours()); + const minutes = pad(date.getMinutes()); + const seconds = pad(date.getSeconds()); + + return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`; +} + +/** + * Calculates the duration between two timestamps + * @param {number} startTime - The start timestamp + * @param {number} endTime - The end timestamp (optional, defaults to current time) + * @returns {string} - The formatted duration string + */ +function calculateDuration(startTime, endTime) { + if (!startTime) return 'N/A'; + + const start = new Date(startTime); + const end = endTime ? new Date(endTime) : new Date(); + + const durationMs = end - start; + const seconds = Math.floor(durationMs / 1000); + + if (seconds < 60) { + return `${seconds} seconds`; + } else if (seconds < 3600) { + const minutes = Math.floor(seconds / 60); + const remainingSeconds = seconds % 60; + return `${minutes} minutes, ${remainingSeconds} seconds`; + } else { + const hours = Math.floor(seconds / 3600); + const remainingMinutes = Math.floor((seconds % 3600) / 60); + return `${hours} hours, ${remainingMinutes} minutes`; + } +} + +/** + * Handles API response and converts it to JSON or throws an error + * @param {Response} response - The fetch API response + * @returns {Promise} - The parsed JSON response + * @throws {Error} - If the response is not ok + */ +function handleResponse(response) { + if (!response.ok) { + return response.text().then(text => { + let error; + try { + error = JSON.parse(text); + } catch (e) { + error = undefined; + } + if (error) { + throw new Error(error.message || `${response.status}: ${text}`); + } else { + throw new Error(`${response.status}: ${text}`); + } + }); + } + return response.json(); +} + +// Export the utility functions +export { formatDate, calculateDuration, handleResponse }; \ No newline at end of file From 348a4459710f632a92a6f67aa6562ed22e8b5a29 Mon Sep 17 00:00:00 2001 From: Deepak Ramalingam Date: Tue, 1 Jul 2025 00:18:08 -0700 Subject: [PATCH 6/6] Prevent clicking on created job --- .../src/main/resources/static/css/styles.css | 8 ++++- .../src/main/resources/static/index.html | 33 ++++++++++--------- .../src/main/resources/static/js/ui.js | 27 ++++++++++----- 3 files changed, 43 insertions(+), 25 deletions(-) diff --git a/maestro-server/src/main/resources/static/css/styles.css b/maestro-server/src/main/resources/static/css/styles.css index 218a9ec4..17938e20 100644 --- a/maestro-server/src/main/resources/static/css/styles.css +++ b/maestro-server/src/main/resources/static/css/styles.css @@ -11,6 +11,12 @@ body { border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); } +.header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 20px; +} table { border-collapse: collapse; width: 100%; @@ -191,4 +197,4 @@ button:hover { } .clickable { cursor: pointer; -} \ No newline at end of file +} diff --git a/maestro-server/src/main/resources/static/index.html b/maestro-server/src/main/resources/static/index.html index 368c11f2..fcfa49cf 100644 --- a/maestro-server/src/main/resources/static/index.html +++ b/maestro-server/src/main/resources/static/index.html @@ -10,22 +10,11 @@
-

Workflow Instances

+
+

Workflow Instances

+ +
- - - - - - - - - - - - - -
Instance IDStatusCreate TimeStart TimeEnd TimeUUID
@@ -55,6 +44,20 @@

Step Details:

+ + + + + + + + + + + + + +
Instance IDStatusCreate TimeStart TimeEnd TimeUUID
diff --git a/maestro-server/src/main/resources/static/js/ui.js b/maestro-server/src/main/resources/static/js/ui.js index ed740d8b..ffea06d8 100644 --- a/maestro-server/src/main/resources/static/js/ui.js +++ b/maestro-server/src/main/resources/static/js/ui.js @@ -44,8 +44,14 @@ function showError(message, errorId = Date.now()) { */ function updateWorkflowInstancesTable(data, onInstanceClick) { const tbody = document.getElementById('result'); - tbody.innerHTML = data.elements.map(instance => ` - + + // Sort instances by instance_id in descending order + const sortedElements = [...data.elements].sort((a, b) => { + return b.workflow_instance_id - a.workflow_instance_id; + }); + + tbody.innerHTML = sortedElements.map(instance => ` + ${instance.workflow_instance_id} ${instance.status} ${formatDate(instance.create_time)} @@ -58,8 +64,11 @@ function updateWorkflowInstancesTable(data, onInstanceClick) { // Add click handlers to instance rows document.querySelectorAll('.instance-row').forEach(row => { row.addEventListener('click', function() { - const instanceId = this.getAttribute('data-instance-id'); - onInstanceClick(instanceId); + const status = this.getAttribute('data-status'); + if (status && status.toLowerCase() !== "created") { + const instanceId = this.getAttribute('data-instance-id'); + onInstanceClick(instanceId); + } }); }); } @@ -92,7 +101,7 @@ function populateRunSelector(instance, onRunChange) { runSelector.addEventListener('change', function() { onRunChange(this.value); }); - + return instance.workflow_run_id; } @@ -289,7 +298,7 @@ function populateAttemptSelector(step, onAttemptChange) { attemptSelector.addEventListener('change', function() { onAttemptChange(step.step_id, this.value); }); - + return step.step_attempt_id; } @@ -330,10 +339,10 @@ function displayStepInfo(step) { function showStepDetails(step, onAttemptChange) { document.getElementById('stepIdDisplay').textContent = step.id; document.getElementById('stepDetailsContainer').style.display = 'block'; - + const attemptId = populateAttemptSelector(step, onAttemptChange); displayStepInfo(step); - + return attemptId; } @@ -346,4 +355,4 @@ export { populateAttemptSelector, displayStepInfo, showStepDetails -}; \ No newline at end of file +};