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/__init__.py +56 -0
- edda/activity.py +505 -0
- edda/app.py +996 -0
- edda/compensation.py +326 -0
- edda/context.py +489 -0
- edda/events.py +505 -0
- edda/exceptions.py +64 -0
- edda/hooks.py +284 -0
- edda/locking.py +322 -0
- edda/outbox/__init__.py +15 -0
- edda/outbox/relayer.py +274 -0
- edda/outbox/transactional.py +112 -0
- edda/pydantic_utils.py +316 -0
- edda/replay.py +799 -0
- edda/retry.py +207 -0
- edda/serialization/__init__.py +9 -0
- edda/serialization/base.py +83 -0
- edda/serialization/json.py +102 -0
- edda/storage/__init__.py +9 -0
- edda/storage/models.py +194 -0
- edda/storage/protocol.py +737 -0
- edda/storage/sqlalchemy_storage.py +1809 -0
- edda/viewer_ui/__init__.py +20 -0
- edda/viewer_ui/app.py +1399 -0
- edda/viewer_ui/components.py +1105 -0
- edda/viewer_ui/data_service.py +880 -0
- edda/visualizer/__init__.py +11 -0
- edda/visualizer/ast_analyzer.py +383 -0
- edda/visualizer/mermaid_generator.py +355 -0
- edda/workflow.py +218 -0
- edda_framework-0.1.0.dist-info/METADATA +748 -0
- edda_framework-0.1.0.dist-info/RECORD +35 -0
- edda_framework-0.1.0.dist-info/WHEEL +4 -0
- edda_framework-0.1.0.dist-info/entry_points.txt +2 -0
- edda_framework-0.1.0.dist-info/licenses/LICENSE +21 -0
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)
|