mirror of
https://github.com/run-llama/workflows-py.git
synced 2026-07-20 01:04:22 -04:00
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:
committed by
GitHub
parent
256d4b3695
commit
c7d29bc548
@@ -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)
|
||||
@@ -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:
|
||||
"""
|
||||
---
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user