edda-framework 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
edda/viewer_ui/app.py ADDED
@@ -0,0 +1,1399 @@
1
+ """
2
+ NiceGUI application for interactive workflow instance visualization.
3
+ """
4
+
5
+ import asyncio
6
+ import contextlib
7
+ import json
8
+ from typing import Any
9
+
10
+ from nicegui import app, ui # type: ignore[import-not-found]
11
+ from nicegui.element import Element # type: ignore[import-not-found]
12
+ from nicegui.events import GenericEventArguments # type: ignore[import-not-found]
13
+
14
+ from edda import EddaApp
15
+ from edda.viewer_ui.components import generate_hybrid_mermaid, generate_interactive_mermaid
16
+ from edda.viewer_ui.data_service import WorkflowDataService
17
+
18
+
19
+ def start_viewer(edda_app: EddaApp, port: int = 8080, reload: bool = False) -> None:
20
+ """
21
+ Start the NiceGUI workflow viewer.
22
+
23
+ Args:
24
+ edda_app: EddaApp instance
25
+ port: Port to run the server on
26
+ reload: Enable auto-reload for development
27
+ """
28
+
29
+ # Initialize storage on NiceGUI startup (to use the correct event loop)
30
+ @app.on_startup # type: ignore[misc]
31
+ async def init_storage() -> None:
32
+ await edda_app.storage.initialize()
33
+
34
+ service = WorkflowDataService(edda_app.storage)
35
+ detail_containers: dict[str, Element] = {}
36
+
37
+ def _render_execution_detail(detail: dict[str, Any]) -> None:
38
+ """
39
+ Render execution detail UI (helper function).
40
+
41
+ Args:
42
+ detail: Execution detail dictionary
43
+ """
44
+ status = detail["status"]
45
+ if status == "completed":
46
+ ui.badge("Completed", color="green").classes("text-lg")
47
+ elif status == "running":
48
+ ui.badge("Running", color="yellow").classes("text-lg")
49
+ elif status == "failed":
50
+ ui.badge("Failed", color="red").classes("text-lg")
51
+ else:
52
+ ui.badge(status, color="gray").classes("text-lg")
53
+
54
+ ui.label(f"Executed: {detail['executed_at']}").classes("text-sm text-gray-600 mt-2")
55
+
56
+ ui.markdown("#### Input")
57
+ with ui.card().classes("w-full bg-gray-50 p-4"):
58
+ ui.code(json.dumps(detail["input"], indent=2)).classes("w-full")
59
+
60
+ if detail["output"] is not None:
61
+ ui.markdown("#### Output")
62
+ with ui.card().classes("w-full bg-gray-50 p-4"):
63
+ ui.code(json.dumps(detail["output"], indent=2)).classes("w-full")
64
+
65
+ if detail["error"]:
66
+ ui.markdown("#### Error")
67
+ with ui.card().classes("w-full bg-red-50 border-red-200 p-4"):
68
+ if detail.get("error_type"):
69
+ ui.label(f"Type: {detail['error_type']}").classes("text-red-700 font-bold")
70
+ ui.label(detail["error"]).classes("text-red-700 font-mono text-sm mt-2")
71
+
72
+ # Show stack trace if available
73
+ if detail.get("stack_trace"):
74
+ with ui.expansion("Stack Trace", icon="bug_report").classes("mt-4 w-full"):
75
+ ui.code(detail["stack_trace"]).classes("w-full text-xs")
76
+
77
+ async def handle_activity_click(event: GenericEventArguments) -> None:
78
+ """Handle activity click event from Mermaid diagram with multi-execution support."""
79
+ try:
80
+ # Event args are now passed as separate arguments: [instance_id, activity_id]
81
+ if isinstance(event.args, list) and len(event.args) >= 2:
82
+ instance_id = event.args[0]
83
+ activity_id = event.args[1]
84
+ else:
85
+ ui.notify(f"Unexpected event.args format: {event.args}", type="warning")
86
+ return
87
+
88
+ # Get single activity detail
89
+ detail = await service.get_activity_detail(instance_id, activity_id)
90
+
91
+ if not detail:
92
+ ui.notify("Activity not found", type="negative")
93
+ return
94
+
95
+ # Get all executions of the same activity
96
+ activity_name = detail["activity_name"]
97
+ all_executions = await service.get_activity_executions(instance_id, activity_name)
98
+
99
+ container = detail_containers.get(instance_id)
100
+ if not container:
101
+ ui.notify("Detail container not found", type="warning")
102
+ return
103
+
104
+ container.clear()
105
+ with container:
106
+ # Header
107
+ if len(all_executions) > 1:
108
+ ui.label(f"{activity_name} (Executed {len(all_executions)} times)").classes(
109
+ "text-2xl font-bold"
110
+ )
111
+ else:
112
+ ui.label(f"{activity_name}: {detail['activity_id']}").classes(
113
+ "text-2xl font-bold"
114
+ )
115
+
116
+ # Multiple executions: use tabs
117
+ if len(all_executions) > 1:
118
+ # Find current execution index
119
+ current_index = next(
120
+ (
121
+ i
122
+ for i, ex in enumerate(all_executions)
123
+ if ex["activity_id"] == activity_id
124
+ ),
125
+ 0,
126
+ )
127
+
128
+ with ui.tabs() as tabs:
129
+ for i, exec_detail in enumerate(all_executions):
130
+ is_current = exec_detail["activity_id"] == activity_id
131
+ # Add arrow indicator for currently clicked activity
132
+ label = f"{'→ ' if is_current else ''}Execution #{i + 1} ({exec_detail['activity_id']})"
133
+ ui.tab(f"exec{i}", label=label)
134
+
135
+ with ui.tab_panels(tabs, value=f"exec{current_index}"):
136
+ for i, exec_detail in enumerate(all_executions):
137
+ with ui.tab_panel(f"exec{i}"):
138
+ _render_execution_detail(exec_detail)
139
+ else:
140
+ # Single execution: render directly
141
+ _render_execution_detail(detail)
142
+
143
+ ui.notify(f"Loaded {activity_name}: {activity_id}", type="positive")
144
+
145
+ except Exception as e:
146
+ ui.notify(f"Error loading activity detail: {e}", type="negative")
147
+
148
+ # Register global event handler (keep both for backward compatibility during migration)
149
+ app.on_connect(lambda: ui.on("step_click", handle_activity_click))
150
+ app.on_connect(lambda: ui.on("activity_click", handle_activity_click))
151
+
152
+ # Define index page
153
+ @ui.page("/") # type: ignore[misc]
154
+ async def index_page() -> None:
155
+ """Workflow instances list page."""
156
+ # Header with title and start button
157
+ with ui.row().classes("w-full items-center justify-between mb-4"):
158
+ ui.markdown("# Edda Workflow Instances")
159
+
160
+ # Start New Workflow button and dialog
161
+ with ui.dialog() as start_dialog, ui.card().style("min-width: 500px"):
162
+ ui.label("Start New Workflow").classes("text-xl font-bold mb-4")
163
+
164
+ # Get all available workflows
165
+ all_workflows = service.get_all_workflows()
166
+ workflow_names = list(all_workflows.keys())
167
+
168
+ if not workflow_names:
169
+ ui.label("No workflows registered").classes("text-red-500")
170
+ ui.button("Close", on_click=start_dialog.close)
171
+ else:
172
+ # Workflow selection
173
+ workflow_select = ui.select(
174
+ workflow_names,
175
+ label="Select Workflow",
176
+ value=workflow_names[0] if workflow_names else None,
177
+ ).classes("w-full mb-4")
178
+
179
+ # Container for dynamic parameter fields
180
+ params_container = ui.column().classes("w-full mb-4")
181
+
182
+ # Store input field references
183
+ param_fields: dict[str, Any] = {}
184
+
185
+ # Factory functions for creating field managers with proper closures
186
+ # These must be defined outside the loop to avoid closure issues
187
+
188
+ def create_nested_dict_field(initial_dict: Any = None) -> Any:
189
+ """Create a nested dict field with dynamic key-value pairs.
190
+
191
+ This is used for list[dict] items, creating a mini dict editor
192
+ for each list item.
193
+ """
194
+
195
+ class DictFieldContainer:
196
+ """Container for dict field with dynamic key-value pairs."""
197
+
198
+ def __init__(self) -> None:
199
+ dict_data = initial_dict if isinstance(initial_dict, dict) else {}
200
+ self.pairs = [[k, v] for k, v in dict_data.items()]
201
+ self.pair_fields: list[list[Any]] = []
202
+
203
+ @ui.refreshable # type: ignore[misc]
204
+ def dict_items_ui() -> None:
205
+ """Refreshable UI for dict key-value pairs."""
206
+ self.pair_fields.clear()
207
+ for i in range(len(self.pairs)):
208
+ with ui.row().classes("w-full gap-2"):
209
+ k_field = (
210
+ ui.input(
211
+ label="Key",
212
+ value=(
213
+ str(self.pairs[i][0])
214
+ if self.pairs[i][0] is not None
215
+ else ""
216
+ ),
217
+ )
218
+ .classes("flex-1")
219
+ .props("dense")
220
+ )
221
+ v_field = (
222
+ ui.input(
223
+ label="Value",
224
+ value=(
225
+ str(self.pairs[i][1])
226
+ if self.pairs[i][1] is not None
227
+ else ""
228
+ ),
229
+ )
230
+ .classes("flex-1")
231
+ .props("dense")
232
+ )
233
+ self.pair_fields.append([k_field, v_field])
234
+ ui.button(
235
+ icon="delete",
236
+ on_click=lambda idx=i: self.remove_pair(idx),
237
+ ).props("flat dense size=sm color=negative")
238
+
239
+ self.dict_items_ui = dict_items_ui
240
+
241
+ # Create the UI
242
+ with ui.column().classes(
243
+ "w-full gap-1 p-2 border rounded bg-gray-50"
244
+ ):
245
+ dict_items_ui()
246
+ ui.button(
247
+ "Add Field", icon="add", on_click=self.add_pair
248
+ ).props("flat dense size=sm color=primary")
249
+
250
+ def add_pair(self) -> None:
251
+ """Add a new key-value pair."""
252
+ self.pairs.append(["", ""])
253
+ self.dict_items_ui.refresh()
254
+
255
+ def remove_pair(self, idx: int) -> None:
256
+ """Remove a key-value pair."""
257
+ if 0 <= idx < len(self.pairs):
258
+ self.pairs.pop(idx)
259
+ self.dict_items_ui.refresh()
260
+
261
+ @property
262
+ def value(self) -> dict[str, Any]:
263
+ """Get the current dict value."""
264
+ result: dict[str, Any] = {}
265
+ for k_field, v_field in self.pair_fields:
266
+ if hasattr(k_field, "value") and hasattr(v_field, "value"):
267
+ k = k_field.value
268
+ v = v_field.value
269
+ if k: # Only add if key is not empty
270
+ result[k] = v
271
+ return result
272
+
273
+ return DictFieldContainer()
274
+
275
+ def create_list_field_manager(
276
+ param_item_type: str, param_initial_items: list[Any]
277
+ ) -> tuple[Any, Any, Any]:
278
+ """Factory function to create list field manager with proper closure."""
279
+ items = list(param_initial_items) # Value storage
280
+ fields: list[Any] = [] # Field reference storage
281
+
282
+ def create_field(item_value: Any = None) -> Any:
283
+ """Create a single list item field."""
284
+ if param_item_type == "str":
285
+ return (
286
+ ui.input(
287
+ value=str(item_value) if item_value is not None else ""
288
+ )
289
+ .classes("w-full")
290
+ .props("dense")
291
+ )
292
+ elif param_item_type == "int":
293
+ return (
294
+ ui.number(
295
+ value=item_value if item_value is not None else 0,
296
+ format="%.0f",
297
+ )
298
+ .classes("w-full")
299
+ .props("dense")
300
+ )
301
+ elif param_item_type == "float":
302
+ return (
303
+ ui.number(
304
+ value=item_value if item_value is not None else 0.0,
305
+ step=0.01,
306
+ format="%.2f",
307
+ )
308
+ .classes("w-full")
309
+ .props("dense")
310
+ )
311
+ elif param_item_type == "bool":
312
+ return ui.checkbox(
313
+ value=item_value if item_value is not None else False
314
+ )
315
+ elif param_item_type == "dict":
316
+ # For list[dict], create nested key-value editor
317
+ return create_nested_dict_field(item_value)
318
+ else:
319
+ # Fallback to JSON
320
+ return (
321
+ ui.textarea(
322
+ value=(
323
+ json.dumps(item_value) if item_value is not None else ""
324
+ )
325
+ )
326
+ .classes("w-full")
327
+ .props("dense")
328
+ )
329
+
330
+ # Create a refreshable component for the list
331
+ @ui.refreshable # type: ignore[misc]
332
+ def list_items_ui() -> None:
333
+ """Refreshable UI for list items."""
334
+ fields.clear()
335
+ for i in range(len(items)):
336
+ with ui.row().classes("w-full items-center gap-2"):
337
+ field = create_field(items[i])
338
+ fields.append(field)
339
+ # Capture index in default argument
340
+ ui.button(
341
+ icon="delete", on_click=lambda idx=i: remove_item(idx)
342
+ ).props("flat dense size=sm color=negative")
343
+
344
+ def add_item() -> None:
345
+ """Add a new item to the list."""
346
+ items.append(None)
347
+ list_items_ui.refresh()
348
+
349
+ def remove_item(index: int) -> None:
350
+ """Remove an item from the list."""
351
+ if 0 <= index < len(items):
352
+ items.pop(index)
353
+ list_items_ui.refresh()
354
+
355
+ def get_value() -> list[Any]:
356
+ """Get the current list value."""
357
+ result = []
358
+ for field in fields:
359
+ if hasattr(field, "value"):
360
+ val = field.value
361
+ # Parse dict items
362
+ if param_item_type == "dict" and isinstance(val, str):
363
+ with contextlib.suppress(json.JSONDecodeError):
364
+ val = json.loads(val)
365
+ result.append(val)
366
+ return result
367
+
368
+ return (add_item, list_items_ui, get_value)
369
+
370
+ def create_dict_field_manager(
371
+ param_key_type: str,
372
+ param_value_type: str,
373
+ param_initial_dict: dict[Any, Any],
374
+ ) -> tuple[Any, Any, Any]:
375
+ """Factory function to create dict field manager with proper closure."""
376
+ pairs = [
377
+ [k, v] for k, v in param_initial_dict.items()
378
+ ] # Value storage [[key, value], ...]
379
+ pair_fields: list[Any] = (
380
+ []
381
+ ) # Field reference storage [[key_field, value_field], ...]
382
+
383
+ def create_key_field(key_value: Any = None) -> Any:
384
+ """Create a key field."""
385
+ if param_key_type == "str":
386
+ return (
387
+ ui.input(
388
+ label="Key",
389
+ value=str(key_value) if key_value is not None else "",
390
+ )
391
+ .classes("flex-1")
392
+ .props("dense")
393
+ )
394
+ elif param_key_type == "int":
395
+ return (
396
+ ui.number(
397
+ label="Key",
398
+ value=key_value if key_value is not None else 0,
399
+ format="%.0f",
400
+ )
401
+ .classes("flex-1")
402
+ .props("dense")
403
+ )
404
+ else:
405
+ return (
406
+ ui.input(
407
+ label="Key",
408
+ value=str(key_value) if key_value is not None else "",
409
+ )
410
+ .classes("flex-1")
411
+ .props("dense")
412
+ )
413
+
414
+ def create_value_field(val: Any = None) -> Any:
415
+ """Create a value field."""
416
+ if param_value_type == "str":
417
+ return (
418
+ ui.input(
419
+ label="Value", value=str(val) if val is not None else ""
420
+ )
421
+ .classes("flex-1")
422
+ .props("dense")
423
+ )
424
+ elif param_value_type == "int":
425
+ return (
426
+ ui.number(
427
+ label="Value",
428
+ value=val if val is not None else 0,
429
+ format="%.0f",
430
+ )
431
+ .classes("flex-1")
432
+ .props("dense")
433
+ )
434
+ elif param_value_type == "float":
435
+ return (
436
+ ui.number(
437
+ label="Value",
438
+ value=val if val is not None else 0.0,
439
+ step=0.01,
440
+ format="%.2f",
441
+ )
442
+ .classes("flex-1")
443
+ .props("dense")
444
+ )
445
+ else:
446
+ return (
447
+ ui.input(
448
+ label="Value", value=str(val) if val is not None else ""
449
+ )
450
+ .classes("flex-1")
451
+ .props("dense")
452
+ )
453
+
454
+ # Create a refreshable component for the pairs
455
+ @ui.refreshable # type: ignore[misc]
456
+ def dict_pairs_ui() -> None:
457
+ """Refreshable UI for dict pairs."""
458
+ pair_fields.clear()
459
+ for i in range(len(pairs)):
460
+ with ui.row().classes("w-full items-center gap-2"):
461
+ key_field = create_key_field(pairs[i][0])
462
+ value_field = create_value_field(pairs[i][1])
463
+ pair_fields.append([key_field, value_field])
464
+ # Capture index in default argument
465
+ ui.button(
466
+ icon="delete", on_click=lambda idx=i: remove_pair(idx)
467
+ ).props("flat dense size=sm color=negative")
468
+
469
+ def add_pair() -> None:
470
+ """Add a new key-value pair."""
471
+ pairs.append([None, None])
472
+ dict_pairs_ui.refresh()
473
+
474
+ def remove_pair(index: int) -> None:
475
+ """Remove a key-value pair."""
476
+ if 0 <= index < len(pairs):
477
+ pairs.pop(index)
478
+ dict_pairs_ui.refresh()
479
+
480
+ def get_value() -> dict[Any, Any]:
481
+ """Get the current dict value."""
482
+ result = {}
483
+ for key_field, value_field in pair_fields:
484
+ if hasattr(key_field, "value") and hasattr(value_field, "value"):
485
+ k = key_field.value
486
+ v = value_field.value
487
+ if k: # Only add if key is not empty
488
+ result[k] = v
489
+ return result
490
+
491
+ return (add_pair, dict_pairs_ui, get_value)
492
+
493
+ def create_list_of_pydantic_manager(
494
+ param_item_fields: dict[str, dict[str, Any]],
495
+ param_initial_items: list[Any] | None = None,
496
+ ) -> tuple[Any, Any, Any]:
497
+ """Factory function to create list[PydanticModel] field manager with proper closure."""
498
+ # Initialize with at least one empty item
499
+ items: list[dict[str, Any]] = (
500
+ list(param_initial_items)
501
+ if param_initial_items
502
+ else [{}] # Start with one empty item
503
+ )
504
+ item_field_refs: list[dict[str, Any]] = [] # Field references for each item
505
+
506
+ def create_item_fields(item_data: dict[str, Any]) -> dict[str, Any]:
507
+ """Create fields for a single Pydantic model item."""
508
+ fields = {}
509
+ for field_name, field_info in param_item_fields.items():
510
+ field_type = field_info["type"]
511
+ required = field_info.get("required", True)
512
+ default = field_info.get("default")
513
+ # Use item_data value if available, otherwise use default
514
+ value = item_data.get(field_name, default)
515
+
516
+ # Add * for required fields
517
+ label = f"{field_name} *" if required else field_name
518
+
519
+ if field_type == "int":
520
+ field = (
521
+ ui.number(
522
+ label=label,
523
+ value=value if value is not None else None,
524
+ format="%.0f",
525
+ )
526
+ .classes("w-full")
527
+ .props("dense")
528
+ )
529
+ elif field_type == "float":
530
+ field = (
531
+ ui.number(
532
+ label=label,
533
+ value=value if value is not None else None,
534
+ step=0.01,
535
+ format="%.2f",
536
+ )
537
+ .classes("w-full")
538
+ .props("dense")
539
+ )
540
+ elif field_type == "bool":
541
+ field = ui.checkbox(
542
+ text=label,
543
+ value=value if value is not None else False,
544
+ ).props("dense")
545
+ elif field_type == "str":
546
+ field = (
547
+ ui.input(
548
+ label=label,
549
+ value=value if value is not None else "",
550
+ )
551
+ .classes("w-full")
552
+ .props("dense")
553
+ )
554
+ elif field_type == "enum":
555
+ # Enum field
556
+ enum_values = field_info.get("enum_values", [])
557
+ options = {val: name for name, val in enum_values}
558
+ default_value = None
559
+ if value is not None:
560
+ default_value = (
561
+ value.value if hasattr(value, "value") else value
562
+ )
563
+ field = (
564
+ ui.select(
565
+ options=options,
566
+ label=label,
567
+ value=default_value,
568
+ )
569
+ .classes("w-full")
570
+ .props("dense")
571
+ )
572
+ else:
573
+ # Fallback to input
574
+ field = (
575
+ ui.input(
576
+ label=label,
577
+ value=str(value) if value is not None else "",
578
+ )
579
+ .classes("w-full")
580
+ .props("dense")
581
+ )
582
+
583
+ fields[field_name] = field
584
+
585
+ return fields
586
+
587
+ # Create a refreshable component for the list of items
588
+ @ui.refreshable # type: ignore[misc]
589
+ def list_items_ui() -> None:
590
+ """Refreshable UI for list of Pydantic model items."""
591
+ item_field_refs.clear()
592
+ for i in range(len(items)):
593
+ # Each item in a bordered container
594
+ with ui.column().classes(
595
+ "w-full border rounded p-2 mb-2 bg-gray-50"
596
+ ):
597
+ with ui.row().classes(
598
+ "w-full items-center justify-between mb-2"
599
+ ):
600
+ ui.label(f"Item {i + 1}").classes("font-semibold text-sm")
601
+ # Remove button (capture index in default argument)
602
+ ui.button(
603
+ icon="delete",
604
+ on_click=lambda idx=i: remove_item(idx),
605
+ ).props("flat dense size=sm color=negative")
606
+
607
+ # Create fields for this item
608
+ item_fields = create_item_fields(items[i])
609
+ item_field_refs.append(item_fields)
610
+
611
+ def add_item() -> None:
612
+ """Add a new item to the list."""
613
+ items.append({}) # Add empty dict
614
+ list_items_ui.refresh()
615
+
616
+ def remove_item(index: int) -> None:
617
+ """Remove an item from the list."""
618
+ if 0 <= index < len(items):
619
+ items.pop(index)
620
+ list_items_ui.refresh()
621
+
622
+ def get_value() -> list[dict[str, Any]]:
623
+ """Get the current list value as list of dicts."""
624
+ result = []
625
+ for item_fields in item_field_refs:
626
+ item_data = {}
627
+ for field_name, field in item_fields.items():
628
+ if hasattr(field, "value"):
629
+ item_data[field_name] = field.value
630
+ result.append(item_data)
631
+ return result
632
+
633
+ return (add_item, list_items_ui, get_value)
634
+
635
+ def update_parameter_fields() -> None:
636
+ """Update parameter input fields based on selected workflow."""
637
+ selected_workflow = workflow_select.value
638
+ if not selected_workflow:
639
+ return
640
+
641
+ # Get parameter information
642
+ params_info = service.get_workflow_parameters(selected_workflow)
643
+
644
+ # Clear existing fields
645
+ params_container.clear()
646
+ param_fields.clear()
647
+
648
+ with params_container:
649
+ if not params_info:
650
+ ui.label("No parameters required").classes(
651
+ "text-sm text-gray-500 italic"
652
+ )
653
+ else:
654
+ ui.label("Parameters:").classes("text-sm font-semibold mb-2")
655
+
656
+ # Group fields by parent for nested model display
657
+ root_fields = {}
658
+ nested_groups: dict[str, dict[str, Any]] = {}
659
+
660
+ for param_name, info in params_info.items():
661
+ if "_parent_field" in info:
662
+ # Nested field - group by parent
663
+ parent = info["_parent_field"]
664
+ if parent not in nested_groups:
665
+ nested_groups[parent] = {}
666
+ nested_groups[parent][param_name] = info
667
+ else:
668
+ # Root-level field
669
+ root_fields[param_name] = info
670
+
671
+ # Helper function to create a single field
672
+ def create_field_ui(param_name: str, info: dict[str, Any]) -> None:
673
+ param_type = info["type"]
674
+ required = info["required"]
675
+ default = info["default"]
676
+
677
+ # Generate label (use simple name for nested fields)
678
+ if "_parent_field" in info:
679
+ # For nested fields like "shipping_address.street", show just "street"
680
+ simple_name = param_name.split(".")[-1]
681
+ label = simple_name
682
+ else:
683
+ label = param_name
684
+
685
+ if required:
686
+ label = f"{label} * [{param_type}]" # * for required fields
687
+ else:
688
+ default_str = (
689
+ str(default) if default is not None else "none"
690
+ )
691
+ label = f"{label} (optional, default: {default_str}) [{param_type}]"
692
+
693
+ # Declare field variable with Any type for mypy
694
+ field: Any
695
+
696
+ # Generate appropriate input field based on type
697
+ if param_type == "int":
698
+ field = ui.number(
699
+ label=label,
700
+ value=default if default is not None else None,
701
+ format="%.0f",
702
+ ).classes("w-full")
703
+ param_fields[param_name] = {
704
+ "field": field,
705
+ "type": param_type,
706
+ "info": info,
707
+ }
708
+
709
+ elif param_type == "float":
710
+ field = ui.number(
711
+ label=label,
712
+ value=default if default is not None else None,
713
+ step=0.01,
714
+ format="%.2f",
715
+ ).classes("w-full")
716
+ param_fields[param_name] = {
717
+ "field": field,
718
+ "type": param_type,
719
+ "info": info,
720
+ }
721
+
722
+ elif param_type == "bool":
723
+ field = ui.checkbox(
724
+ text=label,
725
+ value=default if default is not None else False,
726
+ )
727
+ param_fields[param_name] = {
728
+ "field": field,
729
+ "type": param_type,
730
+ "info": info,
731
+ }
732
+
733
+ elif param_type == "str":
734
+ field = ui.input(
735
+ label=label,
736
+ value=default if default is not None else "",
737
+ ).classes("w-full")
738
+ param_fields[param_name] = {
739
+ "field": field,
740
+ "type": param_type,
741
+ "info": info,
742
+ }
743
+
744
+ elif param_type == "enum":
745
+ # Enum type - dropdown
746
+ enum_values = info.get("enum_values", [])
747
+ # NiceGUI ui.select expects: {key: label}, where key is the internal value
748
+ # We use enum value as key (what we send to CloudEvents) and name as label
749
+ options = {value: name for name, value in enum_values}
750
+
751
+ # Determine default value (should match a key in options)
752
+ default_value = None
753
+ if default is not None:
754
+ # default might be an Enum member
755
+ if hasattr(default, "value"):
756
+ default_value = default.value
757
+ else:
758
+ default_value = default
759
+
760
+ field = ui.select(
761
+ options=options,
762
+ label=label,
763
+ value=default_value,
764
+ ).classes("w-full")
765
+ param_fields[param_name] = {
766
+ "field": field,
767
+ "type": param_type,
768
+ "info": info,
769
+ }
770
+
771
+ elif param_type == "list":
772
+ # List type - dynamic list with add/remove buttons
773
+ # Get item type and initial items
774
+ item_type = info.get("item_type", "json")
775
+ initial_items = (
776
+ default
777
+ if default is not None and isinstance(default, list)
778
+ else []
779
+ )
780
+
781
+ # Create field manager with proper closure (defined outside loop)
782
+ add_item_fn, list_items_ui_fn, get_list_value = (
783
+ create_list_field_manager(item_type, initial_items)
784
+ )
785
+
786
+ # Create container for list items
787
+ with ui.column().classes("w-full border rounded p-2"):
788
+ ui.label(label).classes("font-semibold mb-2")
789
+
790
+ # Render list items using refreshable UI
791
+ list_items_ui_fn()
792
+
793
+ # Add item button (no need to capture container)
794
+ ui.button(
795
+ "+ Add Item", on_click=add_item_fn, icon="add"
796
+ ).props("flat size=sm").classes("mt-2")
797
+
798
+ param_fields[param_name] = {
799
+ "type": param_type,
800
+ "info": info,
801
+ "get_value": get_list_value,
802
+ }
803
+
804
+ elif param_type == "dict":
805
+ # Dict type - dynamic key-value pairs
806
+ # Get key/value types and initial dict
807
+ key_type = info.get("key_type", "str")
808
+ value_type = info.get("value_type", "json")
809
+ initial_dict = (
810
+ default
811
+ if default is not None and isinstance(default, dict)
812
+ else {}
813
+ )
814
+
815
+ # Create field manager with proper closure (defined outside loop)
816
+ add_pair_fn, dict_pairs_ui_fn, get_dict_value = (
817
+ create_dict_field_manager(
818
+ key_type, value_type, initial_dict
819
+ )
820
+ )
821
+
822
+ # Create container for dict pairs
823
+ with ui.column().classes("w-full border rounded p-2"):
824
+ ui.label(label).classes("font-semibold mb-2")
825
+
826
+ # Render dict pairs using refreshable UI
827
+ dict_pairs_ui_fn()
828
+
829
+ # Add pair button (no need to capture container)
830
+ ui.button(
831
+ "+ Add Pair", on_click=add_pair_fn, icon="add"
832
+ ).props("flat size=sm").classes("mt-2")
833
+
834
+ param_fields[param_name] = {
835
+ "type": param_type,
836
+ "info": info,
837
+ "get_value": get_dict_value,
838
+ }
839
+
840
+ elif param_type == "list_of_pydantic":
841
+ # dynamic list with sub-forms
842
+ item_fields = info.get("item_fields", {})
843
+ initial_items = (
844
+ default
845
+ if default is not None and isinstance(default, list)
846
+ else None
847
+ )
848
+
849
+ # Create field manager with proper closure
850
+ add_item_fn, list_items_ui_fn, get_list_value = (
851
+ create_list_of_pydantic_manager(
852
+ item_fields, initial_items
853
+ )
854
+ )
855
+
856
+ # Create container for list of items
857
+ with ui.column().classes("w-full border rounded p-3"):
858
+ ui.label(label).classes("font-semibold mb-2")
859
+
860
+ # Render list items using refreshable UI
861
+ list_items_ui_fn()
862
+
863
+ # Add item button
864
+ ui.button(
865
+ "+ Add Item", on_click=add_item_fn, icon="add"
866
+ ).props("flat size=sm").classes("mt-2")
867
+
868
+ param_fields[param_name] = {
869
+ "type": param_type,
870
+ "info": info,
871
+ "get_value": get_list_value,
872
+ }
873
+
874
+ elif param_type == "json":
875
+ # JSON textarea for nested models and complex types
876
+ description = info.get("description", "")
877
+ json_schema = info.get("json_schema", {})
878
+ schema_type = json_schema.get("type", "")
879
+
880
+ # Generate example JSON based on schema
881
+ placeholder = ""
882
+ if schema_type == "object":
883
+ properties = json_schema.get("properties", {})
884
+ if properties:
885
+ example = dict.fromkeys(
886
+ list(properties.keys())[:3], "..."
887
+ )
888
+ placeholder = json.dumps(example, indent=2)
889
+ else:
890
+ placeholder = '{"key": "value"}'
891
+ elif schema_type == "array":
892
+ items = json_schema.get("items", {})
893
+ items_type = items.get("type", "object")
894
+ if items_type == "object":
895
+ placeholder = '[{"key": "value"}]'
896
+ elif items_type == "string":
897
+ placeholder = '["item1", "item2"]'
898
+ elif items_type == "integer":
899
+ placeholder = "[1, 2, 3]"
900
+ else:
901
+ placeholder = "[]"
902
+ else:
903
+ placeholder = '{"key": "value"}'
904
+
905
+ field = (
906
+ ui.textarea(
907
+ label=f"{label} (JSON)"
908
+ + (f" - {description}" if description else ""),
909
+ placeholder=placeholder,
910
+ value=(
911
+ json.dumps(default, indent=2)
912
+ if default is not None
913
+ else ""
914
+ ),
915
+ )
916
+ .classes("w-full")
917
+ .props("rows=6")
918
+ )
919
+ param_fields[param_name] = {
920
+ "field": field,
921
+ "type": param_type,
922
+ "info": info,
923
+ }
924
+
925
+ else:
926
+ # Fallback to JSON textarea for unknown types
927
+ field = ui.textarea(
928
+ label=f"{label} (JSON)",
929
+ placeholder='{"key": "value"}',
930
+ value=(
931
+ json.dumps(default) if default is not None else ""
932
+ ),
933
+ ).classes("w-full")
934
+ param_fields[param_name] = {
935
+ "field": field,
936
+ "type": param_type,
937
+ "info": info,
938
+ }
939
+
940
+ # End of create_field_ui helper function
941
+
942
+ # Render root fields first
943
+ for param_name, info in root_fields.items():
944
+ create_field_ui(param_name, info)
945
+
946
+ # Render nested field groups with visual grouping
947
+ for parent_name, nested_fields in nested_groups.items():
948
+ # Create a visually grouped container for nested model
949
+ with ui.column().classes(
950
+ "w-full border rounded p-3 bg-gray-50 mt-2"
951
+ ):
952
+ # Parent field label
953
+ ui.label(f"{parent_name} [nested model]").classes(
954
+ "text-sm font-semibold text-gray-700 mb-2"
955
+ )
956
+
957
+ # Render all nested fields
958
+ for nested_param_name, nested_info in nested_fields.items():
959
+ create_field_ui(nested_param_name, nested_info)
960
+
961
+ # Initial parameter fields generation
962
+ update_parameter_fields()
963
+
964
+ # Update fields when workflow selection changes
965
+ workflow_select.on_value_change(lambda _: update_parameter_fields())
966
+
967
+ # Action buttons
968
+ with ui.row().classes("w-full gap-2"):
969
+
970
+ async def handle_start() -> None:
971
+ """Handle workflow start."""
972
+ try:
973
+ selected_workflow = workflow_select.value
974
+ if not selected_workflow:
975
+ ui.notify("Please select a workflow", type="negative")
976
+ return
977
+
978
+ # Collect parameter values from fields
979
+ params: dict[str, Any] = {}
980
+ for param_name, field_info in param_fields.items():
981
+ param_type = field_info["type"]
982
+
983
+ # Get value based on field type
984
+ if "get_value" in field_info:
985
+ # list or dict with custom getter
986
+ value = field_info["get_value"]()
987
+ elif "field" in field_info:
988
+ # Basic types with field.value
989
+ value = field_info["field"].value
990
+ else:
991
+ continue
992
+
993
+ # Skip empty optional fields
994
+ if value is None or value == "":
995
+ continue
996
+
997
+ # Type conversion
998
+ if param_type == "json":
999
+ # Parse JSON for complex types
1000
+ try:
1001
+ params[param_name] = (
1002
+ json.loads(value)
1003
+ if isinstance(value, str)
1004
+ else value
1005
+ )
1006
+ except json.JSONDecodeError as e:
1007
+ ui.notify(
1008
+ f"Invalid JSON for {param_name}: {e}",
1009
+ type="negative",
1010
+ )
1011
+ return
1012
+ elif param_type == "enum":
1013
+ # Enum values are already in the correct format
1014
+ params[param_name] = value
1015
+ elif param_type == "list":
1016
+ # List values are already parsed
1017
+ params[param_name] = value
1018
+ elif param_type == "dict":
1019
+ # Dict values are already parsed
1020
+ params[param_name] = value
1021
+ elif param_type == "list_of_pydantic":
1022
+ # list[PydanticModel] values are already parsed as list[dict]
1023
+ # Filter out empty items (all fields are empty/None)
1024
+ if isinstance(value, list):
1025
+ filtered_items = []
1026
+ for item in value:
1027
+ if isinstance(item, dict):
1028
+ # Check if item has any non-empty values
1029
+ has_value = any(
1030
+ v is not None and v != ""
1031
+ for v in item.values()
1032
+ )
1033
+ if has_value:
1034
+ filtered_items.append(item)
1035
+ # Only add if there are non-empty items
1036
+ if filtered_items:
1037
+ params[param_name] = filtered_items
1038
+ else:
1039
+ params[param_name] = value
1040
+ else:
1041
+ # Direct value for basic types (int, str, float, bool)
1042
+ params[param_name] = value
1043
+
1044
+ # Reconstruct nested model structure
1045
+ # Check for nested fields (_parent_field metadata)
1046
+ nested_field_groups: dict[str, dict[str, Any]] = {}
1047
+ root_params: dict[str, Any] = {}
1048
+
1049
+ for param_name, field_info in param_fields.items():
1050
+ if "_parent_field" in field_info["info"]:
1051
+ # Nested field - extract parent and simple name
1052
+ parent = field_info["info"]["_parent_field"]
1053
+ # param_name is like "shipping_address.street"
1054
+ simple_name = param_name.split(".")[-1]
1055
+
1056
+ if parent not in nested_field_groups:
1057
+ nested_field_groups[parent] = {}
1058
+
1059
+ # Get value from params (already collected above)
1060
+ if param_name in params:
1061
+ nested_field_groups[parent][simple_name] = params[
1062
+ param_name
1063
+ ]
1064
+ else:
1065
+ # Root-level field - keep as is
1066
+ if param_name in params:
1067
+ root_params[param_name] = params[param_name]
1068
+
1069
+ # Rebuild params with nested structure
1070
+ # Filter out empty nested models
1071
+ params = root_params.copy()
1072
+ for parent, nested_fields in nested_field_groups.items():
1073
+ # Check if nested model has any non-empty values
1074
+ has_value = any(
1075
+ v is not None and v != "" for v in nested_fields.values()
1076
+ )
1077
+ # Only add nested model if it has non-empty values
1078
+ if has_value:
1079
+ params[parent] = nested_fields
1080
+
1081
+ # Reconstruct Pydantic model from expanded fields
1082
+ # Check if any field has _pydantic_model_name (indicates expanded fields)
1083
+ pydantic_model_name = None
1084
+ for field_info in param_fields.values():
1085
+ if "_pydantic_model_name" in field_info.get("info", {}):
1086
+ pydantic_model_name = field_info["info"][
1087
+ "_pydantic_model_name"
1088
+ ]
1089
+ break
1090
+
1091
+ if pydantic_model_name:
1092
+ # All expanded fields should be reconstructed into original model structure
1093
+ # params = {field1: value1, field2: value2, ...}
1094
+ # → {model_name: {field1: value1, field2: value2, ...}}
1095
+ params = {pydantic_model_name: params}
1096
+
1097
+ # Get EddaApp URL from environment or use default
1098
+ import os
1099
+
1100
+ edda_app_url = os.getenv("EDDA_APP_URL", "http://localhost:8001")
1101
+
1102
+ ui.notify(
1103
+ f"Starting workflow '{selected_workflow}'...", type="info"
1104
+ )
1105
+
1106
+ # Start workflow
1107
+ success, message, _ = await service.start_workflow(
1108
+ selected_workflow, params, edda_app_url
1109
+ )
1110
+
1111
+ if success:
1112
+ ui.notify(message, type="positive")
1113
+ start_dialog.close()
1114
+ # Refresh page after a short delay
1115
+ await asyncio.sleep(1)
1116
+ ui.navigate.reload()
1117
+ else:
1118
+ ui.notify(f"Failed to start: {message}", type="negative")
1119
+
1120
+ except Exception as e:
1121
+ ui.notify(f"Error: {e}", type="negative")
1122
+
1123
+ ui.button("Start", on_click=handle_start, color="positive")
1124
+ ui.button("Cancel", on_click=start_dialog.close)
1125
+
1126
+ ui.button(
1127
+ "Start New Workflow",
1128
+ on_click=start_dialog.open,
1129
+ icon="play_arrow",
1130
+ color="positive",
1131
+ )
1132
+
1133
+ ui.label("Click on an instance to view execution details").classes("text-gray-600 mb-4")
1134
+
1135
+ instances = await service.get_all_instances(limit=100)
1136
+
1137
+ if not instances:
1138
+ ui.label("No workflow instances found").classes("text-gray-500 italic mt-8")
1139
+ ui.label("Run some workflows first, or click 'Start New Workflow' above!").classes(
1140
+ "text-sm text-gray-400"
1141
+ )
1142
+ return
1143
+
1144
+ with ui.column().classes("w-full gap-2"):
1145
+ for inst in instances:
1146
+ with (
1147
+ ui.link(target=f'/workflow/{inst["instance_id"]}').classes(
1148
+ "no-underline w-full"
1149
+ ),
1150
+ ui.card().classes("w-full cursor-pointer hover:shadow-lg transition-shadow"),
1151
+ ui.row().classes("w-full items-center justify-between"),
1152
+ ):
1153
+ with ui.column():
1154
+ ui.label(inst["workflow_name"]).classes("text-xl font-bold")
1155
+ ui.label(f'ID: {inst["instance_id"][:16]}...').classes(
1156
+ "text-sm text-gray-500"
1157
+ )
1158
+ ui.label(f'Started: {inst["started_at"]}').classes("text-xs text-gray-400")
1159
+
1160
+ status = inst["status"]
1161
+ if status == "completed":
1162
+ ui.badge("✅ Completed", color="green")
1163
+ elif status == "running":
1164
+ ui.badge("⏳ Running", color="yellow")
1165
+ elif status == "failed":
1166
+ ui.badge("❌ Failed", color="red")
1167
+ elif status == "waiting_for_event":
1168
+ ui.badge("⏸️ Waiting (Event)", color="blue")
1169
+ elif status == "waiting_for_timer":
1170
+ ui.badge("⏱️ Waiting (Timer)", color="cyan")
1171
+ elif status == "cancelled":
1172
+ ui.badge("🚫 Cancelled", color="orange")
1173
+ else:
1174
+ ui.badge(status, color="gray")
1175
+
1176
+ # Define detail page
1177
+ @ui.page("/workflow/{instance_id}") # type: ignore[misc]
1178
+ async def workflow_detail_page(instance_id: str) -> None:
1179
+ """Workflow instance detail page with interactive Mermaid diagram."""
1180
+ data = await service.get_instance_detail(instance_id)
1181
+ instance = data.get("instance")
1182
+ history = data.get("history", [])
1183
+ compensations = data.get("compensations", {})
1184
+
1185
+ if not instance:
1186
+ ui.label("Workflow instance not found").classes("text-red-500 text-xl mt-8")
1187
+ ui.button("← Back to list", on_click=lambda: ui.navigate.to("/"))
1188
+ return
1189
+
1190
+ # Header with back button and cancel button
1191
+ with ui.row().classes("w-full items-center justify-between mb-4"):
1192
+ ui.markdown("# Edda Workflow Viewer")
1193
+ with ui.row().classes("gap-2"):
1194
+ # Cancel button (only show for running/waiting workflows)
1195
+ status = instance["status"]
1196
+ if status in ["running", "waiting_for_event", "waiting_for_timer"]:
1197
+
1198
+ async def handle_cancel() -> None:
1199
+ """Handle workflow cancellation."""
1200
+ # Show confirmation dialog with longer timeout
1201
+ try:
1202
+ result = await ui.run_javascript(
1203
+ 'confirm("Are you sure you want to cancel this workflow?")',
1204
+ timeout=5.0, # Increase timeout to 5 seconds
1205
+ )
1206
+ except Exception as e:
1207
+ # If JavaScript fails, proceed anyway
1208
+ print(f"Warning: JavaScript confirmation failed: {e}")
1209
+ result = True # Proceed with cancel
1210
+
1211
+ if result:
1212
+ # Call cancel API
1213
+ edda_url = "http://localhost:8001"
1214
+ success, message = await service.cancel_workflow(instance_id, edda_url)
1215
+
1216
+ if success:
1217
+ ui.notify(message, type="positive")
1218
+ # Refresh page after short delay
1219
+ await asyncio.sleep(0.5)
1220
+ ui.navigate.to(f"/workflow/{instance_id}")
1221
+ else:
1222
+ ui.notify(message, type="negative")
1223
+
1224
+ ui.button("🚫 Cancel Workflow", on_click=handle_cancel).props("color=orange")
1225
+
1226
+ ui.button("← Back to List", on_click=lambda: ui.navigate.to("/")).props("flat")
1227
+
1228
+ # Workflow basic info card (full width at top)
1229
+ with ui.card().classes("w-full mb-4"):
1230
+ ui.label(instance["workflow_name"]).classes("text-2xl font-bold")
1231
+
1232
+ with ui.row().classes("gap-4 items-center flex-wrap"):
1233
+ status = instance["status"]
1234
+ if status == "completed":
1235
+ ui.badge("✅ Completed", color="green")
1236
+ elif status == "running":
1237
+ ui.badge("⏳ Running", color="yellow")
1238
+ elif status == "failed":
1239
+ ui.badge("❌ Failed", color="red")
1240
+ elif status == "waiting_for_event":
1241
+ ui.badge("⏸️ Waiting (Event)", color="blue")
1242
+ elif status == "waiting_for_timer":
1243
+ ui.badge("⏱️ Waiting (Timer)", color="cyan")
1244
+ elif status == "cancelled":
1245
+ ui.badge("🚫 Cancelled", color="orange")
1246
+ elif status == "compensating":
1247
+ ui.badge("🔄 Compensating", color="purple")
1248
+ else:
1249
+ ui.badge(status, color="gray")
1250
+
1251
+ ui.label(f"Started: {instance['started_at']}").classes("text-sm text-gray-600")
1252
+ ui.label(f"Updated: {instance['updated_at']}").classes("text-sm text-gray-600")
1253
+
1254
+ ui.label(f"Instance ID: {instance_id}").classes("text-xs text-gray-500 font-mono mt-2")
1255
+
1256
+ # Input Parameters section
1257
+ input_data = instance.get("input_data")
1258
+ if input_data:
1259
+ with ui.expansion("📥 Input Parameters", icon="input").classes("w-full mt-3"):
1260
+ try:
1261
+ import json
1262
+
1263
+ # Check if input_data is already a dict or needs parsing
1264
+ if isinstance(input_data, dict):
1265
+ formatted_input = json.dumps(input_data, indent=2)
1266
+ else:
1267
+ formatted_input = json.dumps(json.loads(input_data), indent=2)
1268
+ ui.code(formatted_input, language="json").classes("w-full")
1269
+ except Exception:
1270
+ # If anything fails, display as string
1271
+ ui.code(str(input_data)).classes("w-full")
1272
+
1273
+ # Output Result section (only for completed workflows)
1274
+ if status == "completed":
1275
+ output_data = instance.get("output_data")
1276
+ if output_data:
1277
+ with ui.expansion("📤 Output Result", icon="output").classes("w-full mt-2"):
1278
+ try:
1279
+ import json
1280
+
1281
+ # Check if output_data is already a dict or needs parsing
1282
+ if isinstance(output_data, dict):
1283
+ formatted_output = json.dumps(output_data, indent=2)
1284
+ else:
1285
+ formatted_output = json.dumps(json.loads(output_data), indent=2)
1286
+ ui.code(formatted_output, language="json").classes("w-full")
1287
+ except Exception:
1288
+ # If anything fails, display as string
1289
+ ui.code(str(output_data)).classes("w-full")
1290
+
1291
+ # Error Details section (only for failed workflows)
1292
+ if status == "failed":
1293
+ output_data = instance.get("output_data")
1294
+ if output_data:
1295
+ try:
1296
+ import json
1297
+
1298
+ # Parse output_data if it's a JSON string
1299
+ if isinstance(output_data, str):
1300
+ error_data = json.loads(output_data)
1301
+ else:
1302
+ error_data = output_data
1303
+
1304
+ # Check if we have detailed error information
1305
+ if isinstance(error_data, dict) and (
1306
+ "error_message" in error_data or "error_type" in error_data
1307
+ ):
1308
+ with ui.card().classes("w-full mt-2 bg-red-50 border-red-200"):
1309
+ ui.markdown("### ❌ Error Details")
1310
+
1311
+ # Error type (if available)
1312
+ if error_data.get("error_type"):
1313
+ ui.label(f"Type: {error_data['error_type']}").classes(
1314
+ "text-red-700 font-bold text-lg"
1315
+ )
1316
+
1317
+ # Error message
1318
+ error_msg = error_data.get("error_message", "Unknown error")
1319
+ ui.label(error_msg).classes("text-red-700 font-mono text-sm mt-2")
1320
+
1321
+ # Stack trace (expandable section)
1322
+ if error_data.get("stack_trace"):
1323
+ with ui.expansion("📋 Stack Trace", icon="bug_report").classes(
1324
+ "mt-4 w-full"
1325
+ ):
1326
+ ui.code(error_data["stack_trace"]).classes("w-full text-xs")
1327
+ else:
1328
+ # Fallback: old format (just "error" field)
1329
+ with ui.card().classes("w-full mt-2 bg-red-50 border-red-200"):
1330
+ ui.markdown("### ❌ Error")
1331
+ error_msg = error_data.get("error", str(error_data))
1332
+ ui.label(error_msg).classes("text-red-700 font-mono text-sm")
1333
+
1334
+ except Exception:
1335
+ # If parsing fails, show as plain text
1336
+ with ui.card().classes("w-full mt-2 bg-red-50 border-red-200"):
1337
+ ui.markdown("### ❌ Error")
1338
+ ui.label(str(output_data)).classes("text-red-700 font-mono text-sm")
1339
+
1340
+ # Main 2-pane layout (Execution Flow + Activity Details)
1341
+ with ui.row().style("width: 100%; height: calc(100vh - 250px); gap: 1rem; display: flex;"):
1342
+ # Left pane: Execution Flow
1343
+ with ui.column().style("flex: 1; overflow: auto; padding-right: 1rem;"):
1344
+ ui.markdown("## Execution Flow")
1345
+ ui.label("Click on an activity to view details →").classes(
1346
+ "text-gray-600 text-sm mb-2"
1347
+ )
1348
+
1349
+ if history:
1350
+ # Get workflow source code for hybrid diagram
1351
+ # Priority: 1) DB (instance.source_code), 2) Global registry (fallback)
1352
+ workflow_name = instance.get("workflow_name")
1353
+ source_code = instance.get("source_code")
1354
+
1355
+ # Fallback to global registry if DB doesn't have source code
1356
+ if not source_code or source_code.startswith("# Source code not available"):
1357
+ source_code = (
1358
+ service.get_workflow_source(workflow_name) if workflow_name else None
1359
+ )
1360
+
1361
+ if source_code:
1362
+ # Generate hybrid diagram (static analysis + execution history)
1363
+ mermaid_code = generate_hybrid_mermaid(
1364
+ workflow_name,
1365
+ instance_id,
1366
+ history,
1367
+ source_code,
1368
+ compensations,
1369
+ workflow_status=instance["status"],
1370
+ )
1371
+ else:
1372
+ # Fallback to history-only diagram
1373
+ mermaid_code = generate_interactive_mermaid(instance_id, history)
1374
+
1375
+ ui.mermaid(mermaid_code, config={"securityLevel": "loose"}).classes("w-full")
1376
+ else:
1377
+ ui.label("No execution history available").classes("text-gray-500 italic")
1378
+
1379
+ # Right pane: Activity Details
1380
+ with ui.column().style(
1381
+ "flex: 1; overflow: auto; padding: 1rem; background: #f9fafb; border-left: 2px solid #e5e7eb; border-radius: 0.5rem;"
1382
+ ):
1383
+ ui.markdown("## Activity Details")
1384
+ ui.label("Click on an activity in the diagram to view details").classes(
1385
+ "text-gray-500 italic mb-4"
1386
+ )
1387
+
1388
+ detail_container = ui.column().classes("w-full")
1389
+ detail_containers[instance_id] = detail_container
1390
+
1391
+ # Register shutdown handler to clean up EddaApp resources
1392
+ async def shutdown_handler() -> None:
1393
+ """Clean up EddaApp resources on shutdown."""
1394
+ await edda_app.shutdown()
1395
+
1396
+ app.on_shutdown(shutdown_handler)
1397
+
1398
+ # Start server
1399
+ ui.run(port=port, title="Edda Workflow Viewer", reload=reload)