feat: workflow visualization (#104)

* wip: workflow visualization

* feat: update the UI to add execution animation

* feat: clickable nodes with event metadata

* chore: styling 💅

* chore: make the backend agnostic with respect to Cytoscape
This commit is contained in:
Clelia (Astra) Bertelli
2025-09-22 11:38:07 +02:00
committed by GitHub
parent 256d4b3695
commit c7d29bc548
5 changed files with 698 additions and 2 deletions
+95
View File
@@ -117,10 +117,105 @@ class ProcessingWorkflow(Workflow):
return StopEvent(result={"processed_items": results, "total": len(results)})
class ComplicatedInput(StartEvent):
age: int
name: str
terrestrian: bool
language: str
class ChildTerrestrialEvent(Event):
greeting: str
class AdultTerrestrialEvent(Event):
greeting: str
class ExtraTerrestrialEvent(Event):
language: str
greeting: str
class ComplicatedWorkflow(Workflow):
@step
async def first_step(
self,
ev: ComplicatedInput,
ctx: Context,
) -> ChildTerrestrialEvent | AdultTerrestrialEvent | ExtraTerrestrialEvent:
ctx.write_event_to_stream(ev)
await asyncio.sleep(1)
if ev.age < 18 and ev.terrestrian:
ctx.write_event_to_stream(
ChildTerrestrialEvent(
greeting=f"Hello, terrestrial child named {ev.name}"
)
)
return ChildTerrestrialEvent(
greeting=f"Hello, terrestrial child named {ev.name}"
)
elif ev.age >= 18 and ev.terrestrian:
ctx.write_event_to_stream(
AdultTerrestrialEvent(
greeting=f"My regards, terrestrial adult named {ev.name}"
)
)
return AdultTerrestrialEvent(
greeting=f"My regards, terrestrial adult named {ev.name}"
)
else:
if ev.language.lower() == "martian":
ctx.write_event_to_stream(
ExtraTerrestrialEvent(greeting="Ifmmp uifsf!", language="martian")
)
return ExtraTerrestrialEvent(
greeting="Ifmmp uifsf!", language="martian"
)
elif ev.language.lower() == "venusian":
ctx.write_event_to_stream(
ExtraTerrestrialEvent(greeting="!ereht olleH", language="venusian")
)
return ExtraTerrestrialEvent(
greeting="!ereht olleH", language="venusian"
)
else:
ctx.write_event_to_stream(
ExtraTerrestrialEvent(
greeting="Sorry, I do not speak your language",
language=ev.language,
)
)
return ExtraTerrestrialEvent(
greeting="Sorry, I do not speak your language", language=ev.language
)
@step
async def terrestrial_child_step(self, ev: ChildTerrestrialEvent) -> StopEvent:
await asyncio.sleep(1)
return StopEvent(result="Hello back, old person")
@step
async def terrestrial_adult_step(self, ev: AdultTerrestrialEvent) -> StopEvent:
await asyncio.sleep(1)
return StopEvent(result="Hello back, young fella")
@step
async def extraterrestrial_step(self, ev: ExtraTerrestrialEvent) -> StopEvent:
await asyncio.sleep(1)
if ev.language == "martian":
return StopEvent(result="Ifmmp cbdl!")
elif ev.language == "venusian":
return StopEvent(result="kcab olleH")
else:
return StopEvent(result="Xyubpifhabpmfsh!")
async def main() -> None:
server = WorkflowServer()
# Register workflows
server.add_workflow("universe_communication", ComplicatedWorkflow())
server.add_workflow("greeting", GreetingWorkflow())
server.add_workflow("math", MathWorkflow())
server.add_workflow("processing", ProcessingWorkflow())
@@ -0,0 +1,226 @@
from dataclasses import dataclass, asdict
from typing import List, Optional, Any
from workflows.events import (
StopEvent,
InputRequiredEvent,
HumanResponseEvent,
)
from workflows.decorators import StepConfig
from workflows.utils import (
get_steps_from_class,
get_steps_from_instance,
)
from workflows import Workflow
@dataclass
class DrawWorkflowNode:
"""Represents a node in the workflow graph."""
id: str
label: str
node_type: str # 'step', 'event', 'external'
title: Optional[str] = None
event_type: Optional[type] = (
None # Store the actual event type for styling decisions
)
def to_dict(self) -> dict[str, Any]:
d = asdict(self)
ev_type = d.pop("event_type")
if ev_type:
d["event_type"] = ev_type.__name__
else:
d["event_type"] = ev_type
return d
@dataclass
class DrawWorkflowEdge:
"""Represents an edge in the workflow graph."""
source: str
target: str
def to_dict(self) -> dict[str, str]:
return asdict(self)
@dataclass
class DrawWorkflowGraph:
"""Intermediate representation of workflow structure."""
nodes: List[DrawWorkflowNode]
edges: List[DrawWorkflowEdge]
def to_dict(self) -> dict[str, list]:
return {
"nodes": [node.to_dict() for node in self.nodes],
"edges": [edge.to_dict() for edge in self.edges],
}
def _truncate_label(label: str, max_length: int) -> str:
"""Helper to truncate long labels."""
return label if len(label) <= max_length else f"{label[: max_length - 1]}*"
def _extract_workflow_structure(
workflow: Workflow, max_label_length: Optional[int] = None
) -> DrawWorkflowGraph:
"""Extract workflow structure into an intermediate representation."""
# Get workflow steps
steps = get_steps_from_class(workflow)
if not steps:
steps = get_steps_from_instance(workflow)
nodes = []
edges = []
added_nodes = set() # Track added node IDs to avoid duplicates
step_config: Optional[StepConfig] = None
# Only one kind of `StopEvent` is allowed in a `Workflow`.
# Assuming that `Workflow` is validated before drawing, it's enough to find the first one.
current_stop_event = None
for step_name, step_func in steps.items():
step_config = getattr(step_func, "__step_config", None)
if step_config is None:
continue
for return_type in step_config.return_types:
if issubclass(return_type, StopEvent):
current_stop_event = return_type
break
if current_stop_event:
break
# First pass: Add all nodes
for step_name, step_func in steps.items():
step_config = getattr(step_func, "__step_config", None)
if step_config is None:
continue
# Add step node
step_label = (
_truncate_label(step_name, max_label_length)
if max_label_length
else step_name
)
step_title = (
step_name
if max_label_length and len(step_name) > max_label_length
else None
)
if step_name not in added_nodes:
nodes.append(
DrawWorkflowNode(
id=step_name,
label=step_label,
node_type="step",
title=step_title,
)
)
added_nodes.add(step_name)
# Add event nodes for accepted events
for event_type in step_config.accepted_events:
if event_type == StopEvent and event_type != current_stop_event:
continue
event_label = (
_truncate_label(event_type.__name__, max_label_length)
if max_label_length
else event_type.__name__
)
event_title = (
event_type.__name__
if max_label_length and len(event_type.__name__) > max_label_length
else None
)
if event_type.__name__ not in added_nodes:
nodes.append(
DrawWorkflowNode(
id=event_type.__name__,
label=event_label,
node_type="event",
title=event_title,
event_type=event_type,
)
)
added_nodes.add(event_type.__name__)
# Add event nodes for return types
for return_type in step_config.return_types:
if return_type is type(None):
continue
return_label = (
_truncate_label(return_type.__name__, max_label_length)
if max_label_length
else return_type.__name__
)
return_title = (
return_type.__name__
if max_label_length and len(return_type.__name__) > max_label_length
else None
)
if return_type.__name__ not in added_nodes:
nodes.append(
DrawWorkflowNode(
id=return_type.__name__,
label=return_label,
node_type="event",
title=return_title,
event_type=return_type,
)
)
added_nodes.add(return_type.__name__)
# Add external_step node when InputRequiredEvent is found
if (
issubclass(return_type, InputRequiredEvent)
and "external_step" not in added_nodes
):
nodes.append(
DrawWorkflowNode(
id="external_step",
label="external_step",
node_type="external",
)
)
added_nodes.add("external_step")
# Second pass: Add edges
for step_name, step_func in steps.items():
step_config = getattr(step_func, "__step_config", None)
if step_config is None:
continue
# Edges from steps to return types
for return_type in step_config.return_types:
if return_type is not type(None):
edges.append(DrawWorkflowEdge(step_name, return_type.__name__))
if issubclass(return_type, InputRequiredEvent):
edges.append(DrawWorkflowEdge(return_type.__name__, "external_step"))
# Edges from events to steps
for event_type in step_config.accepted_events:
if step_name == "_done" and issubclass(event_type, StopEvent):
if current_stop_event:
edges.append(
DrawWorkflowEdge(current_stop_event.__name__, step_name)
)
else:
edges.append(DrawWorkflowEdge(event_type.__name__, step_name))
if issubclass(event_type, HumanResponseEvent):
edges.append(DrawWorkflowEdge("external_step", event_type.__name__))
return DrawWorkflowGraph(nodes=nodes, edges=edges)
+48
View File
@@ -41,6 +41,7 @@ from workflows.server.abstract_workflow_store import (
Status,
)
from .utils import nanoid
from .representation_utils import _extract_workflow_structure
logger = logging.getLogger()
@@ -117,6 +118,11 @@ class WorkflowServer:
self._get_handlers,
methods=["GET"],
),
Route(
"/workflows/{name}/representation",
self._get_workflow_representation,
methods=["GET"],
),
]
@asynccontextmanager
@@ -381,6 +387,48 @@ class WorkflowServer:
return JSONResponse({"start": start_event_schema, "stop": stop_event_schema})
async def _get_workflow_representation(self, request: Request) -> JSONResponse:
"""
---
summary: Get the representation of the workflow
description: |
Get the representation of the workflow as a directed graph in JSON format
parameters:
- in: path
name: name
required: true
schema:
type: string
description: Registered workflow name.
requestBody:
required: false
responses:
200:
description: JSON representation successfully retrieved
content:
application/json:
schema:
type: object
properties:
graph:
description: the elements of the JSON representation of the workflow
required: [graph]
404:
description: Workflow not found
500:
description: Error while getting JSON workflow representation
"""
workflow = self._extract_workflow(request)
try:
workflow_graph = _extract_workflow_structure(workflow.workflow)
except Exception as e:
raise HTTPException(
detail=f"Error while getting JSON workflow representation: {e}",
status_code=500,
)
return JSONResponse({"graph": workflow_graph.to_dict()})
async def _run_workflow_nowait(self, request: Request) -> JSONResponse:
"""
---
+14 -1
View File
@@ -4,7 +4,18 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Workflow UI</title>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/cytoscape/3.26.0/cytoscape.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/dagre/0.8.5/dagre.min.js"></script>
<script src="https://unpkg.com/cytoscape-dagre@2.5.0/cytoscape-dagre.js"></script>
<script>
// Register dagre with cytoscape when page loads
document.addEventListener('DOMContentLoaded', function() {
if (typeof cytoscape !== 'undefined' && typeof dagre !== 'undefined') {
cytoscape.use(cytoscapeDagre);
}
});
</script>
</head>
<body class="bg-gray-100 p-4 md:p-8">
<div class="max-w-6xl mx-auto">
@@ -17,6 +28,8 @@
<label for="workflow-select" class="block text-sm font-medium text-gray-700">Workflow</label>
<select id="workflow-select" class="mt-1 block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md"></select>
</div>
<div class="bg-gray-50 shadow-xl rounded-lg mt-8 mb-8" id="workflowViz"></div>
<div class="bg-gray-50 shadow-xl rounded-lg border border-gray-200 mt-8 mb-8 px-4 py-1 hidden" id="nodeDescription"></div>
<!-- Dynamic Form Container -->
<div id="dynamic-form-container">
<!-- Loading state -->
+315 -1
View File
@@ -3,11 +3,15 @@ document.addEventListener('DOMContentLoaded', () => {
const runButton = document.getElementById('run-button');
const runsContainer = document.getElementById('runs');
const eventStreamContainer = document.getElementById('event-stream');
const workflowViz = document.getElementById('workflowViz')
const nodeDescription = document.getElementById('nodeDescription')
let activeRunId = null;
const eventStreams = {};
let cy = null;
let currentSchema = null;
let currentOutput = null;
let vizData = null;
// Fetch workflows on page load
fetch('/workflows')
@@ -37,6 +41,12 @@ document.addEventListener('DOMContentLoaded', () => {
runButton.disabled = true;
});
nodeDescription.addEventListener('click', (e) => {
if (e.target && e.target.id === 'hideNodeDescription') {
nodeDescription.classList.add("hidden");
}
});
runButton.addEventListener('click', () => {
const workflowName = workflowSelect.value;
if (!workflowName) {
@@ -131,9 +141,23 @@ document.addEventListener('DOMContentLoaded', () => {
if (line.trim() === '') continue;
try {
const eventData = JSON.parse(line);
highlightNode(eventData.qualified_name.split(".").at(-1))
let eventDetails = "";
let currentStepName = "";
for (const key in eventData.value) {
const value = eventData.value[key];
if (eventData.qualified_name === "workflows.events.StepStateChanged" && key === "name") {
currentStepName = value
highlightNode(currentStepName)
if (vizData) {
for (const element of vizData.elements) {
if (element.data.id === currentStepName) {
element.data.inputEvent = eventData.value["input_event_name"].replace("<class", "").replace(">", "").replaceAll("'", "").split(".").at(-1) ?? "Not recorded";
element.data.outputEvent = eventData.value["output_event_name"].replace("<class", "").replace(">", "").replaceAll("'", "").split(".").at(-1) ?? "Not recorded";
}
}
}
}
if (!value || value.toString().trim() === '') {
eventDetails += `<details class="mb-2"><summary class="cursor-pointer text-gray-700 hover:text-gray-900 font-medium">${key}</summary><p class="mt-1 ml-4 text-gray-600 text-sm">No data</p></details>`;
} else {
@@ -142,6 +166,12 @@ document.addEventListener('DOMContentLoaded', () => {
}
const formattedEvent = `<div class="mb-4 p-3 bg-white rounded border border-gray-200"><strong class="text-gray-800">Event:</strong> <span class="text-blue-600 font-mono text-sm">${eventData.qualified_name}</span><br><strong class="text-gray-800">Data:</strong><div class="mt-2">${eventDetails}</div></div>`;
eventStreams[handlerId].push(formattedEvent);
requestAnimationFrame(() => {
setTimeout(() => {
resetNode(eventData.qualified_name.replace("__main__.", "").replace("workflows.events.", ""))
resetNode(currentStepName)
}, 500); // Still add a small delay for visibility
});
if (handlerId === activeRunId) {
eventStreamContainer.innerHTML += formattedEvent;
@@ -574,7 +604,7 @@ document.addEventListener('DOMContentLoaded', () => {
if (!selectedWorkflow) {
// Reset to fallback form if no workflow selected
generateFormFields(null);
generateOutputFields(null)
generateOutputFields(null);
return;
}
@@ -588,8 +618,292 @@ document.addEventListener('DOMContentLoaded', () => {
currentOutput = stopSchema;
generateOutputFields(stopSchema);
}
fetchWorkflowViz(selectedWorkflow)
}
async function fetchWorkflowViz(selectedWorkflow) {
const workflowViz = document.getElementById('workflowViz');
try {
const response = await fetch(`/workflows/${selectedWorkflow}/representation`);
if (!response.ok) {
const errData = await response.json().catch(() => ({}));
workflowViz.innerHTML = `<p class="text-red-500 text-lg">An error occurred while retrieving the visualization for the workflow<br>${response.status}: ${JSON.stringify(errData)}<br>Try with another workflow</p>`;
workflowViz.classList.replace("bg-gray-50", "bg-red-300");
return;
}
const vizJson = await response.json();
const vizDataRaw = vizJson.graph;
const vizElements = []
for (const node of vizDataRaw.nodes) {
node_data = {
"data": {
"id": node.id,
"label": node.label,
"type": node.node_type,
},
}
if (node.node_type === "step") {
node_data.classes = "node-step"
} else if (node.node_type === "event") {
node_data.classes = "node-event"
} else if (node.node_type === "external") {
node_data.classes = "node-external"
} else {
node_data.classes = "node"
}
if (node.title) {
node_data.data.title = node.title
}
if (node.event_type) {
node_data.data.event_type = node.event_type
}
vizElements.push(node_data)
}
for (const edge of vizDataRaw.edges) {
edge_data = {
"data": {
"id": `${edge.source}-${edge.target}`,
"source": edge.source,
"target": edge.target,
}
}
vizElements.push(edge_data)
}
vizData = {"elements": vizElements}
// Clear the container and reset styling
workflowViz.innerHTML = '';
workflowViz.classList.replace("bg-red-300", "bg-gray-50");
// Set container height for Cytoscape
workflowViz.style.height = '600px';
workflowViz.style.width = '100%';
// Initialize Cytoscape
cy = cytoscape({
container: workflowViz,
elements: vizData.elements,
style: [
// Default styles if not provided by backend
{
selector: '.node-step',
style: {
'background-color': '#92AEFF',
'label': 'data(label)',
'text-valign': 'top',
'text-halign': 'center',
'color': 'white',
'text-outline-width': 1,
'text-outline-color': '#3E18F9',
'shape': 'rectangle',
'width': 'label',
'height': 40,
'font-size': '14px',
'font-weight': 'bold',
'padding': '10px',
'border-width': 1,
'border-color': '#2980b9',
'border-style': 'solid'
}
},
{
selector: 'node',
style: {
'label': 'data(label)',
'text-valign': 'top',
'text-halign': 'center',
'color': 'white',
'font-size': '12px',
'font-weight': 'bold',
'text-outline-width': 1,
'text-outline-color': '#000'
}
},
{
selector: '.node-event',
style: {
'background-color': '#FDEDBA',
'label': 'data(label)',
'text-valign': 'top',
'text-halign': 'center',
'color': 'white',
'text-outline-width': 1,
'text-outline-color': '#FF8705',
'shape': 'diamond',
'width': 'label',
'height': 'label',
'font-size': '12px',
'font-weight': 'bold',
'padding': '10px',
'border-width': 1,
'border-color': '#2980b9',
'border-style': 'solid'
}
},
{
selector: '.node-external',
style: {
'background-color': '#f39c12',
'label': 'data(label)',
'text-valign': 'top',
'text-halign': 'center',
'color': 'white',
'shape': 'diamond',
'width': 'label',
'height': 'label',
'font-size': '12px',
'font-weight': 'bold',
'padding': '15px'
}
},
{
selector: '.workflow-edge',
style: {
'curve-style': 'bezier',
'target-arrow-shape': 'triangle',
'arrow-scale': 1.5,
'line-color': '#666',
'target-arrow-color': '#666',
'width': 2
}
},
{
selector: 'edge',
style: {
'curve-style': 'bezier',
'target-arrow-shape': 'triangle',
'arrow-scale': 1.5,
'line-color': '#666',
'target-arrow-color': '#666',
'width': 2
}
},
{
selector: '.node-step:selected',
style: {
'background-color': '#4B72FE',
'border-width': 3,
'border-style': 'solid',
'border-opacity': 0.8
}
},
{
selector: '.node-event:selected',
style: {
'background-color': '#FFBD74',
'border-width': 3,
'border-style': 'solid',
'border-opacity': 0.8
}
},
{
selector: 'node:selected',
style: {
'border-width': 3,
'border-color': '#34495e',
'border-opacity': 0.8
}
}
],
layout: {
name: 'dagre',
directed: true,
rankDir: 'LR', // Left to Right
spacingFactor: 1.5,
nodeSep: 50,
rankSep: 100,
padding: 30
},
// Interactive options
userZoomingEnabled: true,
userPanningEnabled: true,
boxSelectionEnabled: false,
selectionType: 'single',
touchTapThreshold: 8,
desktopTapThreshold: 4,
autolock: false,
autoungrabify: false,
autounselectify: false
});
// Add click event for nodes (optional - shows node info)
cy.on('tap', 'node', function(event) {
const node = event.target;
const data = node.data();
let info = `<strong class="text-center text-xl">Node ${data.label}</strong><br><strong>Type</strong>: ${data.type}`;
if (data.title) info += `<br><strong>Title</strong>: ${data.title}`;
if (data.event_type) info += `<br><strong>Event Type</strong>: ${data.event_type}`;
if (data.inputEvent) info += `<br><strong>Input Event (last call)</strong>: ${data.inputEvent}`
if (data.outputEvent) info += `<br><strong>Output Event (last call)</strong>: ${data.outputEvent}<br>`
// Remove all possible background colors first
nodeDescription.classList.remove("bg-gray-50", "bg-[#92AEFF]", "bg-[#FDEDBA]", "bg-[#FFBFF8]");
nodeDescription.classList.remove("hidden")
let bgButton = "bg-blue-600"
let txtButton = "text-white"
// Then add the correct one
if (data.type === "step") {
nodeDescription.classList.add("bg-[#92AEFF]");
bgButton = "bg-[#4B72FE]"
} else if (data.type === "event") {
nodeDescription.classList.add("bg-[#FDEDBA]");
bgButton = "bg-[#FFBD74]"
txtButton = "text-gray-700"
} else {
nodeDescription.classList.add("bg-[#FFBFF8]");
}
nodeDescription.innerHTML = `<p class="text-gray-700 text-lg p-4">${info}</p><br><button id="hideNodeDescription" class="w-full ${bgButton} ${txtButton} font-bold text-center rounded-lg shadow-lg">Hide Description</button>`;
});
// Fit the graph to the container
cy.fit();
cy.center();
} catch (error) {
console.error('Error fetching workflow visualization:', error);
workflowViz.innerHTML = `<p class="text-red-500 text-lg">An unexpected error occurred while loading the workflow visualization.<br>Please try again.</p>`;
workflowViz.classList.replace("bg-gray-50", "bg-red-300");
}
}
// Change a specific node by ID
function highlightNode(nodeId, color = '#43BF69') {
const node = cy.getElementById(nodeId);
if (node.length === 0) {
return;
}
node.style({
'background-color': color,
'border-width': 3,
'text-outline-color': color,
});
}
// Reset node to original style
function resetNode(nodeId) {
const node = cy.getElementById(nodeId);
if (node.length === 0) {
return;
}
node.removeStyle();
}
document.getElementById('workflow-select').addEventListener('change', handleWorkflowSelectChange);
// Initial form fields