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/css/styles.css b/maestro-server/src/main/resources/static/css/styles.css
new file mode 100644
index 00000000..17938e20
--- /dev/null
+++ b/maestro-server/src/main/resources/static/css/styles.css
@@ -0,0 +1,200 @@
+body {
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Arial, sans-serif;
+ margin: 20px;
+ background-color: #f5f5f5;
+}
+.container {
+ max-width: 1200px;
+ margin: 0 auto;
+ background-color: white;
+ padding: 20px;
+ 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%;
+ margin-top: 20px;
+ background-color: white;
+ box-shadow: 0 1px 3px rgba(0,0,0,0.1);
+}
+th, td {
+ border: 1px solid #eee;
+ padding: 12px;
+ text-align: left;
+}
+th {
+ background-color: #f8f9fa;
+ font-weight: 600;
+}
+tr:hover {
+ background-color: #f8f9fa;
+}
+.status-succeeded {
+ color: #2e7d32;
+ font-weight: 500;
+}
+.status-created {
+ color: #1976d2;
+ font-weight: 500;
+}
+.status-failed {
+ 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;
+ cursor: pointer;
+ background-color: #1976d2;
+ color: white;
+ border: none;
+ border-radius: 4px;
+ font-size: 14px;
+ transition: background-color 0.2s;
+}
+button:hover {
+ background-color: #1565c0;
+}
+.errors-container {
+ margin-bottom: 20px;
+}
+.error-message {
+ background-color: #ffebee;
+ color: #c62828;
+ padding: 12px;
+ margin: 8px 0;
+ border: 1px solid #ef9a9a;
+ border-radius: 4px;
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+}
+.error-dismiss {
+ background: none;
+ border: none;
+ color: #c62828;
+ padding: 4px 8px;
+ cursor: pointer;
+ font-size: 18px;
+ margin: 0;
+}
+.error-dismiss:hover {
+ 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;
+}
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..fcfa49cf
--- /dev/null
+++ b/maestro-server/src/main/resources/static/index.html
@@ -0,0 +1,65 @@
+
+
+
+
+ Maestro
+
+
+
+
+
+
+
+
+
+
+
+
+
Workflow Instance:
+
+
+
+ Select Run:
+
+
+
+
+
Workflow Steps
+
+
+
+
+
Step Details:
+
+
+
+ Select Attempt:
+
+
+
+
+
+
+
+
+
+
+
+ Instance ID
+ Status
+ Create Time
+ Start Time
+ End Time
+ UUID
+
+
+
+
+
+
+
+
+
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..ffea06d8
--- /dev/null
+++ b/maestro-server/src/main/resources/static/js/ui.js
@@ -0,0 +1,358 @@
+/**
+ * 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');
+
+ // 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)}
+ ${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 status = this.getAttribute('data-status');
+ if (status && status.toLowerCase() !== "created") {
+ 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
+};
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