edda-framework 0.5.0__py3-none-any.whl → 0.7.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/integrations/mcp/decorators.py +101 -5
- edda/integrations/mcp/server.py +36 -15
- edda/storage/protocol.py +18 -4
- edda/storage/sqlalchemy_storage.py +105 -5
- edda/viewer_ui/app.py +552 -126
- edda/viewer_ui/components.py +81 -68
- edda/viewer_ui/data_service.py +42 -3
- edda/viewer_ui/theme.py +200 -0
- {edda_framework-0.5.0.dist-info → edda_framework-0.7.0.dist-info}/METADATA +8 -5
- {edda_framework-0.5.0.dist-info → edda_framework-0.7.0.dist-info}/RECORD +13 -12
- {edda_framework-0.5.0.dist-info → edda_framework-0.7.0.dist-info}/WHEEL +1 -1
- {edda_framework-0.5.0.dist-info → edda_framework-0.7.0.dist-info}/entry_points.txt +0 -0
- {edda_framework-0.5.0.dist-info → edda_framework-0.7.0.dist-info}/licenses/LICENSE +0 -0
edda/viewer_ui/app.py
CHANGED
|
@@ -5,6 +5,7 @@ NiceGUI application for interactive workflow instance visualization.
|
|
|
5
5
|
import asyncio
|
|
6
6
|
import contextlib
|
|
7
7
|
import json
|
|
8
|
+
from datetime import datetime
|
|
8
9
|
from typing import Any
|
|
9
10
|
|
|
10
11
|
from nicegui import app, ui # type: ignore[import-not-found]
|
|
@@ -14,6 +15,7 @@ from nicegui.events import GenericEventArguments # type: ignore[import-not-foun
|
|
|
14
15
|
from edda import EddaApp
|
|
15
16
|
from edda.viewer_ui.components import generate_hybrid_mermaid, generate_interactive_mermaid
|
|
16
17
|
from edda.viewer_ui.data_service import WorkflowDataService
|
|
18
|
+
from edda.viewer_ui.theme import TAILWIND_CLASSES, get_status_badge_classes
|
|
17
19
|
|
|
18
20
|
|
|
19
21
|
def start_viewer(edda_app: EddaApp, port: int = 8080, reload: bool = False) -> None:
|
|
@@ -42,32 +44,39 @@ def start_viewer(edda_app: EddaApp, port: int = 8080, reload: bool = False) -> N
|
|
|
42
44
|
detail: Execution detail dictionary
|
|
43
45
|
"""
|
|
44
46
|
status = detail["status"]
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
47
|
+
status_labels = {
|
|
48
|
+
"completed": "Completed",
|
|
49
|
+
"running": "Running",
|
|
50
|
+
"failed": "Failed",
|
|
51
|
+
}
|
|
52
|
+
label_text = status_labels.get(status, status)
|
|
53
|
+
ui.label(label_text).classes(f"text-lg {get_status_badge_classes(status)}")
|
|
54
|
+
|
|
55
|
+
ui.label(f"Executed: {detail['executed_at']}").classes(
|
|
56
|
+
f"text-sm mt-2 {TAILWIND_CLASSES['text_secondary']}"
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
ui.markdown("#### Input").classes(TAILWIND_CLASSES["text_primary"])
|
|
60
|
+
with ui.card().classes(f"w-full p-4 {TAILWIND_CLASSES['code_block']}"):
|
|
58
61
|
ui.code(json.dumps(detail["input"], indent=2)).classes("w-full")
|
|
59
62
|
|
|
60
63
|
if detail["output"] is not None:
|
|
61
|
-
ui.markdown("#### Output")
|
|
62
|
-
with ui.card().classes("w-full
|
|
64
|
+
ui.markdown("#### Output").classes(TAILWIND_CLASSES["text_primary"])
|
|
65
|
+
with ui.card().classes(f"w-full p-4 {TAILWIND_CLASSES['code_block']}"):
|
|
63
66
|
ui.code(json.dumps(detail["output"], indent=2)).classes("w-full")
|
|
64
67
|
|
|
65
68
|
if detail["error"]:
|
|
66
|
-
ui.markdown("#### Error")
|
|
67
|
-
with ui.card().classes(
|
|
69
|
+
ui.markdown("#### Error").classes(TAILWIND_CLASSES["text_primary"])
|
|
70
|
+
with ui.card().classes(
|
|
71
|
+
"w-full p-4 bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-800"
|
|
72
|
+
):
|
|
68
73
|
if detail.get("error_type"):
|
|
69
|
-
ui.label(f"Type: {detail['error_type']}").classes(
|
|
70
|
-
|
|
74
|
+
ui.label(f"Type: {detail['error_type']}").classes(
|
|
75
|
+
"text-red-700 dark:text-red-400 font-bold"
|
|
76
|
+
)
|
|
77
|
+
ui.label(detail["error"]).classes(
|
|
78
|
+
"text-red-700 dark:text-red-400 font-mono text-sm mt-2"
|
|
79
|
+
)
|
|
71
80
|
|
|
72
81
|
# Show stack trace if available
|
|
73
82
|
if detail.get("stack_trace"):
|
|
@@ -106,11 +115,11 @@ def start_viewer(edda_app: EddaApp, port: int = 8080, reload: bool = False) -> N
|
|
|
106
115
|
# Header
|
|
107
116
|
if len(all_executions) > 1:
|
|
108
117
|
ui.label(f"{activity_name} (Executed {len(all_executions)} times)").classes(
|
|
109
|
-
"text-2xl font-bold"
|
|
118
|
+
f"text-2xl font-bold {TAILWIND_CLASSES['text_primary']}"
|
|
110
119
|
)
|
|
111
120
|
else:
|
|
112
|
-
ui.label(
|
|
113
|
-
"text-2xl font-bold"
|
|
121
|
+
ui.label(detail["activity_id"]).classes(
|
|
122
|
+
f"text-2xl font-bold {TAILWIND_CLASSES['text_primary']}"
|
|
114
123
|
)
|
|
115
124
|
|
|
116
125
|
# Multiple executions: use tabs
|
|
@@ -140,7 +149,7 @@ def start_viewer(edda_app: EddaApp, port: int = 8080, reload: bool = False) -> N
|
|
|
140
149
|
# Single execution: render directly
|
|
141
150
|
_render_execution_detail(detail)
|
|
142
151
|
|
|
143
|
-
ui.notify(f"Loaded {
|
|
152
|
+
ui.notify(f"Loaded {activity_id}", type="positive")
|
|
144
153
|
|
|
145
154
|
except Exception as e:
|
|
146
155
|
ui.notify(f"Error loading activity detail: {e}", type="negative")
|
|
@@ -149,38 +158,173 @@ def start_viewer(edda_app: EddaApp, port: int = 8080, reload: bool = False) -> N
|
|
|
149
158
|
app.on_connect(lambda: ui.on("step_click", handle_activity_click))
|
|
150
159
|
app.on_connect(lambda: ui.on("activity_click", handle_activity_click))
|
|
151
160
|
|
|
161
|
+
# Initialize dark mode from system preference on first visit
|
|
162
|
+
async def init_dark_mode() -> None:
|
|
163
|
+
if "dark_mode" not in app.storage.user:
|
|
164
|
+
# First visit: detect system preference
|
|
165
|
+
try:
|
|
166
|
+
is_dark = await ui.run_javascript(
|
|
167
|
+
"window.matchMedia('(prefers-color-scheme: dark)').matches",
|
|
168
|
+
timeout=1.0,
|
|
169
|
+
)
|
|
170
|
+
app.storage.user["dark_mode"] = bool(is_dark)
|
|
171
|
+
except Exception:
|
|
172
|
+
# Default to light mode if detection fails
|
|
173
|
+
app.storage.user["dark_mode"] = False
|
|
174
|
+
|
|
175
|
+
app.on_connect(init_dark_mode)
|
|
176
|
+
|
|
152
177
|
# Define index page
|
|
153
178
|
@ui.page("/") # type: ignore[misc]
|
|
154
179
|
async def index_page() -> None:
|
|
155
180
|
"""Workflow instances list page."""
|
|
156
|
-
#
|
|
181
|
+
# Bind dark mode to user storage (persists across pages and sessions)
|
|
182
|
+
ui.dark_mode().bind_value(app.storage.user, "dark_mode")
|
|
183
|
+
|
|
184
|
+
# Custom CSS and JS to ensure dark mode background is applied
|
|
185
|
+
ui.add_head_html(
|
|
186
|
+
"""
|
|
187
|
+
<style>
|
|
188
|
+
/* Page background */
|
|
189
|
+
body.dark, body.body--dark {
|
|
190
|
+
background-color: #0F172A !important; /* Slate 900 */
|
|
191
|
+
}
|
|
192
|
+
body:not(.dark):not(.body--dark) {
|
|
193
|
+
background-color: #FFFFFF !important;
|
|
194
|
+
}
|
|
195
|
+
.nicegui-content, .q-page, .q-layout {
|
|
196
|
+
background-color: transparent !important;
|
|
197
|
+
}
|
|
198
|
+
/* Fixed layout for index page - filter bar at top, pager at bottom, list scrolls */
|
|
199
|
+
.nicegui-content {
|
|
200
|
+
display: flex !important;
|
|
201
|
+
flex-direction: column !important;
|
|
202
|
+
height: 100vh !important;
|
|
203
|
+
overflow: hidden !important;
|
|
204
|
+
}
|
|
205
|
+
.instance-list-container {
|
|
206
|
+
flex: 1 !important;
|
|
207
|
+
overflow-y: auto !important;
|
|
208
|
+
min-height: 0 !important;
|
|
209
|
+
}
|
|
210
|
+
.pagination-controls {
|
|
211
|
+
flex-shrink: 0 !important;
|
|
212
|
+
}
|
|
213
|
+
/* Card backgrounds - Light mode (exclude error cards) */
|
|
214
|
+
body:not(.dark):not(.body--dark) .q-card:not(.bg-red-50) {
|
|
215
|
+
background-color: #FFFFFF !important;
|
|
216
|
+
border-color: #E2E8F0 !important; /* Slate 200 */
|
|
217
|
+
}
|
|
218
|
+
body:not(.dark):not(.body--dark) .q-card:not(.bg-red-50):hover {
|
|
219
|
+
background-color: #F8FAFC !important; /* Slate 50 */
|
|
220
|
+
}
|
|
221
|
+
/* Card backgrounds - Dark mode (exclude error cards) */
|
|
222
|
+
body.dark .q-card:not(.bg-red-50), body.body--dark .q-card:not(.bg-red-50) {
|
|
223
|
+
background-color: #1E293B !important; /* Slate 800 */
|
|
224
|
+
border-color: #334155 !important; /* Slate 700 */
|
|
225
|
+
}
|
|
226
|
+
body.dark .q-card:not(.bg-red-50):hover, body.body--dark .q-card:not(.bg-red-50):hover {
|
|
227
|
+
background-color: #334155 !important; /* Slate 700 */
|
|
228
|
+
}
|
|
229
|
+
/* Error card backgrounds - Dark mode */
|
|
230
|
+
body.dark .q-card.bg-red-50, body.body--dark .q-card.bg-red-50 {
|
|
231
|
+
background-color: rgba(127, 29, 29, 0.3) !important; /* Red 900/30 */
|
|
232
|
+
border-color: #991B1B !important; /* Red 800 */
|
|
233
|
+
}
|
|
234
|
+
body.dark .bg-red-50 .text-red-700, body.body--dark .bg-red-50 .text-red-700 {
|
|
235
|
+
color: #FCA5A5 !important; /* Red 300 */
|
|
236
|
+
}
|
|
237
|
+
/* Input fields - Light mode */
|
|
238
|
+
body:not(.dark):not(.body--dark) .q-field__control {
|
|
239
|
+
background-color: #FFFFFF !important;
|
|
240
|
+
}
|
|
241
|
+
/* Input fields - Dark mode */
|
|
242
|
+
body.dark .q-field__control, body.body--dark .q-field__control {
|
|
243
|
+
background-color: #1E293B !important;
|
|
244
|
+
}
|
|
245
|
+
body.dark .q-field__native, body.body--dark .q-field__native,
|
|
246
|
+
body.dark .q-field__input, body.body--dark .q-field__input {
|
|
247
|
+
color: #F1F5F9 !important; /* Slate 100 */
|
|
248
|
+
}
|
|
249
|
+
</style>
|
|
250
|
+
<script>
|
|
251
|
+
function applyDarkModeStyles(isDark) {
|
|
252
|
+
// Body background
|
|
253
|
+
document.body.style.backgroundColor = isDark ? '#0F172A' : '#FFFFFF';
|
|
254
|
+
// All cards (exclude error cards with bg-red-50)
|
|
255
|
+
document.querySelectorAll('.q-card, .nicegui-card').forEach(card => {
|
|
256
|
+
if (!card.classList.contains('bg-red-50')) {
|
|
257
|
+
card.style.backgroundColor = isDark ? '#1E293B' : '#FFFFFF';
|
|
258
|
+
card.style.borderColor = isDark ? '#334155' : '#E2E8F0';
|
|
259
|
+
}
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
// Watch for dark mode class changes on body
|
|
263
|
+
const observer = new MutationObserver((mutations) => {
|
|
264
|
+
mutations.forEach((mutation) => {
|
|
265
|
+
if (mutation.attributeName === 'class') {
|
|
266
|
+
const isDark = document.body.classList.contains('dark') ||
|
|
267
|
+
document.body.classList.contains('body--dark');
|
|
268
|
+
applyDarkModeStyles(isDark);
|
|
269
|
+
}
|
|
270
|
+
});
|
|
271
|
+
});
|
|
272
|
+
observer.observe(document.body, { attributes: true });
|
|
273
|
+
// Initial check and periodic re-apply (for dynamically added cards)
|
|
274
|
+
const isDark = document.body.classList.contains('dark') ||
|
|
275
|
+
document.body.classList.contains('body--dark');
|
|
276
|
+
applyDarkModeStyles(isDark);
|
|
277
|
+
setInterval(() => {
|
|
278
|
+
const isDark = document.body.classList.contains('dark') ||
|
|
279
|
+
document.body.classList.contains('body--dark');
|
|
280
|
+
applyDarkModeStyles(isDark);
|
|
281
|
+
}, 500);
|
|
282
|
+
</script>
|
|
283
|
+
"""
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
# Theme toggle handler (theme_button will be defined below)
|
|
287
|
+
def toggle_theme() -> None:
|
|
288
|
+
current = app.storage.user.get("dark_mode", False)
|
|
289
|
+
app.storage.user["dark_mode"] = not current
|
|
290
|
+
# Update icon: show sun in dark mode, moon in light mode
|
|
291
|
+
new_icon = "dark_mode" if current else "light_mode"
|
|
292
|
+
theme_button.props(f"icon={new_icon} flat round")
|
|
293
|
+
|
|
294
|
+
# Header with title, start button, and theme toggle
|
|
157
295
|
with ui.row().classes("w-full items-center justify-between mb-4"):
|
|
158
|
-
ui.markdown("# Edda Workflow Instances")
|
|
296
|
+
ui.markdown("# Edda Workflow Instances").classes("text-slate-900 dark:text-slate-100")
|
|
159
297
|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
298
|
+
with ui.row().classes("items-center gap-4"):
|
|
299
|
+
# Start New Workflow button and dialog
|
|
300
|
+
with (
|
|
301
|
+
ui.dialog() as start_dialog,
|
|
302
|
+
ui.card().classes(f"{TAILWIND_CLASSES['card']} p-6").style("min-width: 500px"),
|
|
303
|
+
):
|
|
304
|
+
ui.label("Start New Workflow").classes(
|
|
305
|
+
f"text-xl font-bold mb-4 {TAILWIND_CLASSES['text_primary']}"
|
|
306
|
+
)
|
|
163
307
|
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
308
|
+
# Get all available workflows
|
|
309
|
+
all_workflows = service.get_all_workflows()
|
|
310
|
+
workflow_names = list(all_workflows.keys())
|
|
167
311
|
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
312
|
+
if not workflow_names:
|
|
313
|
+
ui.label("No workflows registered").classes("text-red-500")
|
|
314
|
+
ui.button("Close", on_click=start_dialog.close)
|
|
315
|
+
else:
|
|
316
|
+
# Workflow selection
|
|
317
|
+
workflow_select = ui.select(
|
|
318
|
+
workflow_names,
|
|
319
|
+
label="Select Workflow",
|
|
320
|
+
value=workflow_names[0] if workflow_names else None,
|
|
321
|
+
).classes("w-full mb-4")
|
|
178
322
|
|
|
179
|
-
|
|
180
|
-
|
|
323
|
+
# Container for dynamic parameter fields
|
|
324
|
+
params_container = ui.column().classes("w-full mb-4")
|
|
181
325
|
|
|
182
|
-
|
|
183
|
-
|
|
326
|
+
# Store input field references
|
|
327
|
+
param_fields: dict[str, Any] = {}
|
|
184
328
|
|
|
185
329
|
# Factory functions for creating field managers with proper closures
|
|
186
330
|
# These must be defined outside the loop to avoid closure issues
|
|
@@ -240,7 +384,7 @@ def start_viewer(edda_app: EddaApp, port: int = 8080, reload: bool = False) -> N
|
|
|
240
384
|
|
|
241
385
|
# Create the UI
|
|
242
386
|
with ui.column().classes(
|
|
243
|
-
"w-full gap-1 p-2 border rounded bg-gray-50"
|
|
387
|
+
"w-full gap-1 p-2 border rounded bg-gray-50 dark:bg-slate-800 dark:border-slate-700"
|
|
244
388
|
):
|
|
245
389
|
dict_items_ui()
|
|
246
390
|
ui.button(
|
|
@@ -592,7 +736,7 @@ def start_viewer(edda_app: EddaApp, port: int = 8080, reload: bool = False) -> N
|
|
|
592
736
|
for i in range(len(items)):
|
|
593
737
|
# Each item in a bordered container
|
|
594
738
|
with ui.column().classes(
|
|
595
|
-
"w-full border rounded p-2 mb-2 bg-gray-50"
|
|
739
|
+
"w-full border rounded p-2 mb-2 bg-gray-50 dark:bg-slate-800 dark:border-slate-700"
|
|
596
740
|
):
|
|
597
741
|
with ui.row().classes(
|
|
598
742
|
"w-full items-center justify-between mb-2"
|
|
@@ -947,11 +1091,11 @@ def start_viewer(edda_app: EddaApp, port: int = 8080, reload: bool = False) -> N
|
|
|
947
1091
|
for parent_name, nested_fields in nested_groups.items():
|
|
948
1092
|
# Create a visually grouped container for nested model
|
|
949
1093
|
with ui.column().classes(
|
|
950
|
-
"w-full border rounded p-3 bg-gray-50 mt-2"
|
|
1094
|
+
"w-full border rounded p-3 bg-gray-50 dark:bg-slate-800 dark:border-slate-700 mt-2"
|
|
951
1095
|
):
|
|
952
1096
|
# Parent field label
|
|
953
1097
|
ui.label(f"{parent_name} [nested model]").classes(
|
|
954
|
-
"text-sm font-semibold text-gray-700 mb-2"
|
|
1098
|
+
"text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2"
|
|
955
1099
|
)
|
|
956
1100
|
|
|
957
1101
|
# Render all nested fields
|
|
@@ -1123,74 +1267,337 @@ def start_viewer(edda_app: EddaApp, port: int = 8080, reload: bool = False) -> N
|
|
|
1123
1267
|
ui.button("Start", on_click=handle_start, color="positive")
|
|
1124
1268
|
ui.button("Cancel", on_click=start_dialog.close)
|
|
1125
1269
|
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1270
|
+
ui.button(
|
|
1271
|
+
"Start New Workflow",
|
|
1272
|
+
on_click=start_dialog.open,
|
|
1273
|
+
icon="play_arrow",
|
|
1274
|
+
color="positive",
|
|
1275
|
+
)
|
|
1132
1276
|
|
|
1133
|
-
|
|
1277
|
+
# Theme toggle button (rightmost)
|
|
1278
|
+
is_dark = app.storage.user.get("dark_mode", False)
|
|
1279
|
+
icon = "light_mode" if is_dark else "dark_mode"
|
|
1280
|
+
theme_button = (
|
|
1281
|
+
ui.button(on_click=toggle_theme)
|
|
1282
|
+
.props(f"icon={icon} flat round")
|
|
1283
|
+
.classes("text-slate-600 dark:text-slate-300")
|
|
1284
|
+
)
|
|
1134
1285
|
|
|
1135
|
-
|
|
1286
|
+
ui.label("Click on an instance to view execution details").classes(
|
|
1287
|
+
"text-slate-600 dark:text-slate-400 mb-4"
|
|
1288
|
+
)
|
|
1289
|
+
|
|
1290
|
+
# State management for pagination
|
|
1291
|
+
pagination_state: dict[str, Any] = {
|
|
1292
|
+
"current_token": None,
|
|
1293
|
+
"token_stack": [], # Stack for "Previous" navigation
|
|
1294
|
+
"page_size": 20,
|
|
1295
|
+
"status_filter": None,
|
|
1296
|
+
"search_query": "",
|
|
1297
|
+
"started_after": None,
|
|
1298
|
+
"started_before": None,
|
|
1299
|
+
"instances": [],
|
|
1300
|
+
"next_page_token": None,
|
|
1301
|
+
"has_more": False,
|
|
1302
|
+
}
|
|
1303
|
+
|
|
1304
|
+
# Filter bar (placed below page title, above instance list)
|
|
1305
|
+
with (
|
|
1306
|
+
ui.card().classes(f"w-full mb-4 p-4 flex-shrink-0 {TAILWIND_CLASSES['card']}"),
|
|
1307
|
+
ui.row().classes("w-full items-end gap-4 flex-wrap"),
|
|
1308
|
+
):
|
|
1309
|
+
# Search input
|
|
1310
|
+
search_input = ui.input(
|
|
1311
|
+
label="Search (name or ID)",
|
|
1312
|
+
placeholder="Enter workflow name or instance ID...",
|
|
1313
|
+
).classes("w-64")
|
|
1314
|
+
|
|
1315
|
+
# Status filter
|
|
1316
|
+
status_options = {
|
|
1317
|
+
"": "All Statuses",
|
|
1318
|
+
"running": "Running",
|
|
1319
|
+
"completed": "Completed",
|
|
1320
|
+
"failed": "Failed",
|
|
1321
|
+
"waiting_for_event": "Waiting (Event)",
|
|
1322
|
+
"waiting_for_timer": "Waiting (Timer)",
|
|
1323
|
+
"cancelled": "Cancelled",
|
|
1324
|
+
}
|
|
1325
|
+
status_select = ui.select(
|
|
1326
|
+
options=status_options,
|
|
1327
|
+
value="",
|
|
1328
|
+
label="Status",
|
|
1329
|
+
).classes("w-40")
|
|
1330
|
+
|
|
1331
|
+
# Page size selector
|
|
1332
|
+
page_size_select = ui.select(
|
|
1333
|
+
options={10: "10", 20: "20", 50: "50"},
|
|
1334
|
+
value=20,
|
|
1335
|
+
label="Per page",
|
|
1336
|
+
).classes("w-24")
|
|
1337
|
+
|
|
1338
|
+
# Date range inputs with calendar picker
|
|
1339
|
+
with ui.input(label="From").classes("w-36") as date_from:
|
|
1340
|
+
with ui.menu() as date_from_menu:
|
|
1341
|
+
ui.date().bind_value(date_from)
|
|
1342
|
+
with date_from.add_slot("append"):
|
|
1343
|
+
ui.icon("event").classes("cursor-pointer").on(
|
|
1344
|
+
"click", lambda m=date_from_menu: m.open()
|
|
1345
|
+
)
|
|
1346
|
+
|
|
1347
|
+
with ui.input(label="To").classes("w-36") as date_to:
|
|
1348
|
+
with ui.menu() as date_to_menu:
|
|
1349
|
+
ui.date().bind_value(date_to)
|
|
1350
|
+
with date_to.add_slot("append"):
|
|
1351
|
+
ui.icon("event").classes("cursor-pointer").on(
|
|
1352
|
+
"click", lambda m=date_to_menu: m.open()
|
|
1353
|
+
)
|
|
1136
1354
|
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
"
|
|
1355
|
+
# Refresh button
|
|
1356
|
+
async def handle_refresh() -> None:
|
|
1357
|
+
"""Handle refresh button click."""
|
|
1358
|
+
pagination_state["search_query"] = search_input.value
|
|
1359
|
+
pagination_state["status_filter"] = status_select.value or None
|
|
1360
|
+
pagination_state["page_size"] = page_size_select.value
|
|
1361
|
+
pagination_state["started_after"] = date_from.value
|
|
1362
|
+
pagination_state["started_before"] = date_to.value
|
|
1363
|
+
# Reset to first page
|
|
1364
|
+
pagination_state["current_token"] = None
|
|
1365
|
+
pagination_state["token_stack"] = []
|
|
1366
|
+
await refresh_list()
|
|
1367
|
+
|
|
1368
|
+
ui.button("Refresh", on_click=handle_refresh, icon="refresh").props(
|
|
1369
|
+
"flat color=primary"
|
|
1141
1370
|
)
|
|
1142
|
-
return
|
|
1143
1371
|
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1372
|
+
# Container for the instance list (will be refreshed)
|
|
1373
|
+
# Uses instance-list-container class for scroll behavior (CSS defined above)
|
|
1374
|
+
list_container = ui.column().classes("w-full instance-list-container")
|
|
1375
|
+
|
|
1376
|
+
async def load_instances() -> None:
|
|
1377
|
+
"""Load instances with current filter settings."""
|
|
1378
|
+
# Parse date filters
|
|
1379
|
+
started_after = None
|
|
1380
|
+
started_before = None
|
|
1381
|
+
if pagination_state["started_after"]:
|
|
1382
|
+
with contextlib.suppress(ValueError):
|
|
1383
|
+
started_after = datetime.fromisoformat(
|
|
1384
|
+
pagination_state["started_after"] + "T00:00:00"
|
|
1385
|
+
)
|
|
1386
|
+
if pagination_state["started_before"]:
|
|
1387
|
+
with contextlib.suppress(ValueError):
|
|
1388
|
+
started_before = datetime.fromisoformat(
|
|
1389
|
+
pagination_state["started_before"] + "T23:59:59"
|
|
1390
|
+
)
|
|
1391
|
+
|
|
1392
|
+
result = await service.get_instances_paginated(
|
|
1393
|
+
page_size=pagination_state["page_size"],
|
|
1394
|
+
page_token=pagination_state["current_token"],
|
|
1395
|
+
status_filter=pagination_state["status_filter"],
|
|
1396
|
+
search_query=pagination_state["search_query"] or None,
|
|
1397
|
+
started_after=started_after,
|
|
1398
|
+
started_before=started_before,
|
|
1399
|
+
)
|
|
1400
|
+
pagination_state["instances"] = result["instances"]
|
|
1401
|
+
pagination_state["next_page_token"] = result["next_page_token"]
|
|
1402
|
+
pagination_state["has_more"] = result["has_more"]
|
|
1403
|
+
|
|
1404
|
+
async def refresh_list() -> None:
|
|
1405
|
+
"""Refresh the instance list display."""
|
|
1406
|
+
await load_instances()
|
|
1407
|
+
list_container.clear()
|
|
1408
|
+
with list_container:
|
|
1409
|
+
render_instance_list()
|
|
1410
|
+
|
|
1411
|
+
def render_instance_list() -> None:
|
|
1412
|
+
"""Render the instance list cards."""
|
|
1413
|
+
instances = pagination_state["instances"]
|
|
1414
|
+
if not instances:
|
|
1415
|
+
ui.label("No workflow instances found").classes(
|
|
1416
|
+
"text-slate-500 dark:text-slate-400 italic mt-8"
|
|
1417
|
+
)
|
|
1418
|
+
ui.label("Run some workflows first, or click 'Start New Workflow' above!").classes(
|
|
1419
|
+
"text-sm text-slate-400 dark:text-slate-500"
|
|
1420
|
+
)
|
|
1421
|
+
return
|
|
1422
|
+
|
|
1423
|
+
with ui.column().classes("w-full gap-2"):
|
|
1424
|
+
for inst in instances:
|
|
1425
|
+
with (
|
|
1426
|
+
ui.link(target=f'/workflow/{inst["instance_id"]}').classes(
|
|
1427
|
+
"no-underline w-full"
|
|
1428
|
+
),
|
|
1429
|
+
ui.card().classes(
|
|
1430
|
+
f"w-full cursor-pointer hover:shadow-lg transition-shadow {TAILWIND_CLASSES['card']} {TAILWIND_CLASSES['card_hover']}"
|
|
1431
|
+
),
|
|
1432
|
+
ui.row().classes("w-full items-center justify-between"),
|
|
1433
|
+
):
|
|
1434
|
+
with ui.column().classes("flex-1 min-w-0"):
|
|
1435
|
+
ui.label(inst["workflow_name"]).classes(
|
|
1436
|
+
f"text-xl font-bold truncate {TAILWIND_CLASSES['text_primary']}"
|
|
1437
|
+
)
|
|
1438
|
+
ui.label(f'ID: {inst["instance_id"]}').classes(
|
|
1439
|
+
f"text-sm truncate {TAILWIND_CLASSES['text_secondary']}"
|
|
1440
|
+
)
|
|
1441
|
+
ui.label(f'Started: {inst["started_at"]}').classes(
|
|
1442
|
+
f"text-sm truncate {TAILWIND_CLASSES['text_secondary']}"
|
|
1443
|
+
)
|
|
1444
|
+
|
|
1445
|
+
status = inst["status"]
|
|
1446
|
+
status_labels = {
|
|
1447
|
+
"completed": "✅ Completed",
|
|
1448
|
+
"running": "⏳ Running",
|
|
1449
|
+
"failed": "❌ Failed",
|
|
1450
|
+
"waiting_for_event": "⏸️ Waiting (Event)",
|
|
1451
|
+
"waiting_for_timer": "⏱️ Waiting (Timer)",
|
|
1452
|
+
"cancelled": "🚫 Cancelled",
|
|
1453
|
+
}
|
|
1454
|
+
label_text = status_labels.get(status, status)
|
|
1455
|
+
ui.label(label_text).classes(get_status_badge_classes(status))
|
|
1456
|
+
|
|
1457
|
+
# Initial load
|
|
1458
|
+
await load_instances()
|
|
1459
|
+
with list_container:
|
|
1460
|
+
render_instance_list()
|
|
1461
|
+
|
|
1462
|
+
# Pagination controls (fixed at bottom via pagination-controls class)
|
|
1463
|
+
with ui.row().classes("w-full justify-end gap-4 mt-4 pagination-controls"):
|
|
1464
|
+
|
|
1465
|
+
async def handle_previous() -> None:
|
|
1466
|
+
"""Handle Previous button click."""
|
|
1467
|
+
if pagination_state["token_stack"]:
|
|
1468
|
+
# Pop the last token from the stack
|
|
1469
|
+
pagination_state["current_token"] = pagination_state["token_stack"].pop()
|
|
1470
|
+
await refresh_list()
|
|
1471
|
+
|
|
1472
|
+
async def handle_next() -> None:
|
|
1473
|
+
"""Handle Next button click."""
|
|
1474
|
+
if pagination_state["has_more"] and pagination_state["next_page_token"]:
|
|
1475
|
+
# Push current token to stack before moving to next page
|
|
1476
|
+
pagination_state["token_stack"].append(pagination_state["current_token"])
|
|
1477
|
+
pagination_state["current_token"] = pagination_state["next_page_token"]
|
|
1478
|
+
await refresh_list()
|
|
1479
|
+
|
|
1480
|
+
ui.button("← Previous", on_click=handle_previous, icon="chevron_left").props("flat")
|
|
1481
|
+
ui.button("Next →", on_click=handle_next, icon="chevron_right").props("flat")
|
|
1175
1482
|
|
|
1176
1483
|
# Define detail page
|
|
1177
1484
|
@ui.page("/workflow/{instance_id}") # type: ignore[misc]
|
|
1178
1485
|
async def workflow_detail_page(instance_id: str) -> None:
|
|
1179
1486
|
"""Workflow instance detail page with interactive Mermaid diagram."""
|
|
1487
|
+
# Bind dark mode to user storage (persists across pages and sessions)
|
|
1488
|
+
ui.dark_mode().bind_value(app.storage.user, "dark_mode")
|
|
1489
|
+
|
|
1490
|
+
# Custom CSS and JS to ensure dark mode background is applied
|
|
1491
|
+
ui.add_head_html(
|
|
1492
|
+
"""
|
|
1493
|
+
<style>
|
|
1494
|
+
/* Page background */
|
|
1495
|
+
body.dark, body.body--dark {
|
|
1496
|
+
background-color: #0F172A !important; /* Slate 900 */
|
|
1497
|
+
}
|
|
1498
|
+
body:not(.dark):not(.body--dark) {
|
|
1499
|
+
background-color: #FFFFFF !important;
|
|
1500
|
+
}
|
|
1501
|
+
.nicegui-content, .q-page, .q-layout {
|
|
1502
|
+
background-color: transparent !important;
|
|
1503
|
+
}
|
|
1504
|
+
/* Card backgrounds - Light mode (exclude error cards) */
|
|
1505
|
+
body:not(.dark):not(.body--dark) .q-card:not(.bg-red-50) {
|
|
1506
|
+
background-color: #FFFFFF !important;
|
|
1507
|
+
border-color: #E2E8F0 !important; /* Slate 200 */
|
|
1508
|
+
}
|
|
1509
|
+
body:not(.dark):not(.body--dark) .q-card:not(.bg-red-50):hover {
|
|
1510
|
+
background-color: #F8FAFC !important; /* Slate 50 */
|
|
1511
|
+
}
|
|
1512
|
+
/* Card backgrounds - Dark mode (exclude error cards) */
|
|
1513
|
+
body.dark .q-card:not(.bg-red-50), body.body--dark .q-card:not(.bg-red-50) {
|
|
1514
|
+
background-color: #1E293B !important; /* Slate 800 */
|
|
1515
|
+
border-color: #334155 !important; /* Slate 700 */
|
|
1516
|
+
}
|
|
1517
|
+
body.dark .q-card:not(.bg-red-50):hover, body.body--dark .q-card:not(.bg-red-50):hover {
|
|
1518
|
+
background-color: #334155 !important; /* Slate 700 */
|
|
1519
|
+
}
|
|
1520
|
+
/* Error card backgrounds - Dark mode */
|
|
1521
|
+
body.dark .q-card.bg-red-50, body.body--dark .q-card.bg-red-50 {
|
|
1522
|
+
background-color: rgba(127, 29, 29, 0.3) !important; /* Red 900/30 */
|
|
1523
|
+
border-color: #991B1B !important; /* Red 800 */
|
|
1524
|
+
}
|
|
1525
|
+
body.dark .bg-red-50 .text-red-700, body.body--dark .bg-red-50 .text-red-700 {
|
|
1526
|
+
color: #FCA5A5 !important; /* Red 300 */
|
|
1527
|
+
}
|
|
1528
|
+
/* Input fields - Light mode */
|
|
1529
|
+
body:not(.dark):not(.body--dark) .q-field__control {
|
|
1530
|
+
background-color: #FFFFFF !important;
|
|
1531
|
+
}
|
|
1532
|
+
/* Input fields - Dark mode */
|
|
1533
|
+
body.dark .q-field__control, body.body--dark .q-field__control {
|
|
1534
|
+
background-color: #1E293B !important;
|
|
1535
|
+
}
|
|
1536
|
+
body.dark .q-field__native, body.body--dark .q-field__native,
|
|
1537
|
+
body.dark .q-field__input, body.body--dark .q-field__input {
|
|
1538
|
+
color: #F1F5F9 !important; /* Slate 100 */
|
|
1539
|
+
}
|
|
1540
|
+
</style>
|
|
1541
|
+
<script>
|
|
1542
|
+
function applyDarkModeStyles(isDark) {
|
|
1543
|
+
// Body background
|
|
1544
|
+
document.body.style.backgroundColor = isDark ? '#0F172A' : '#FFFFFF';
|
|
1545
|
+
// All cards (exclude error cards with bg-red-50)
|
|
1546
|
+
document.querySelectorAll('.q-card, .nicegui-card').forEach(card => {
|
|
1547
|
+
if (!card.classList.contains('bg-red-50')) {
|
|
1548
|
+
card.style.backgroundColor = isDark ? '#1E293B' : '#FFFFFF';
|
|
1549
|
+
card.style.borderColor = isDark ? '#334155' : '#E2E8F0';
|
|
1550
|
+
}
|
|
1551
|
+
});
|
|
1552
|
+
}
|
|
1553
|
+
// Watch for dark mode class changes on body
|
|
1554
|
+
const observer = new MutationObserver((mutations) => {
|
|
1555
|
+
mutations.forEach((mutation) => {
|
|
1556
|
+
if (mutation.attributeName === 'class') {
|
|
1557
|
+
const isDark = document.body.classList.contains('dark') ||
|
|
1558
|
+
document.body.classList.contains('body--dark');
|
|
1559
|
+
applyDarkModeStyles(isDark);
|
|
1560
|
+
}
|
|
1561
|
+
});
|
|
1562
|
+
});
|
|
1563
|
+
observer.observe(document.body, { attributes: true });
|
|
1564
|
+
// Initial check and periodic re-apply (for dynamically added cards)
|
|
1565
|
+
const isDark = document.body.classList.contains('dark') ||
|
|
1566
|
+
document.body.classList.contains('body--dark');
|
|
1567
|
+
applyDarkModeStyles(isDark);
|
|
1568
|
+
setInterval(() => {
|
|
1569
|
+
const isDark = document.body.classList.contains('dark') ||
|
|
1570
|
+
document.body.classList.contains('body--dark');
|
|
1571
|
+
applyDarkModeStyles(isDark);
|
|
1572
|
+
}, 500);
|
|
1573
|
+
</script>
|
|
1574
|
+
"""
|
|
1575
|
+
)
|
|
1576
|
+
|
|
1180
1577
|
data = await service.get_instance_detail(instance_id)
|
|
1181
1578
|
instance = data.get("instance")
|
|
1182
1579
|
history = data.get("history", [])
|
|
1183
1580
|
compensations = data.get("compensations", {})
|
|
1184
1581
|
|
|
1185
1582
|
if not instance:
|
|
1186
|
-
ui.label("Workflow instance not found").classes(
|
|
1583
|
+
ui.label("Workflow instance not found").classes(
|
|
1584
|
+
"text-red-500 dark:text-red-400 text-xl mt-8"
|
|
1585
|
+
)
|
|
1187
1586
|
ui.button("← Back to list", on_click=lambda: ui.navigate.to("/"))
|
|
1188
1587
|
return
|
|
1189
1588
|
|
|
1589
|
+
# Theme toggle handler (theme_button will be defined below)
|
|
1590
|
+
def toggle_theme() -> None:
|
|
1591
|
+
current = app.storage.user.get("dark_mode", False)
|
|
1592
|
+
app.storage.user["dark_mode"] = not current
|
|
1593
|
+
# Update icon: show sun in dark mode, moon in light mode
|
|
1594
|
+
new_icon = "dark_mode" if current else "light_mode"
|
|
1595
|
+
theme_button.props(f"icon={new_icon} flat round")
|
|
1596
|
+
|
|
1190
1597
|
# Header with back button and cancel button
|
|
1191
1598
|
with ui.row().classes("w-full items-center justify-between mb-4"):
|
|
1192
|
-
ui.markdown("# Edda Workflow Viewer")
|
|
1193
|
-
with ui.row().classes("gap-
|
|
1599
|
+
ui.markdown("# Edda Workflow Viewer").classes("text-slate-900 dark:text-slate-100")
|
|
1600
|
+
with ui.row().classes("gap-4 items-center"):
|
|
1194
1601
|
# Cancel button (only show for running/waiting workflows)
|
|
1195
1602
|
status = instance["status"]
|
|
1196
1603
|
if status in ["running", "waiting_for_event", "waiting_for_timer"]:
|
|
@@ -1225,33 +1632,45 @@ def start_viewer(edda_app: EddaApp, port: int = 8080, reload: bool = False) -> N
|
|
|
1225
1632
|
|
|
1226
1633
|
ui.button("← Back to List", on_click=lambda: ui.navigate.to("/")).props("flat")
|
|
1227
1634
|
|
|
1635
|
+
# Theme toggle button (rightmost)
|
|
1636
|
+
is_dark = app.storage.user.get("dark_mode", False)
|
|
1637
|
+
icon = "light_mode" if is_dark else "dark_mode"
|
|
1638
|
+
theme_button = (
|
|
1639
|
+
ui.button(on_click=toggle_theme)
|
|
1640
|
+
.props(f"icon={icon} flat round")
|
|
1641
|
+
.classes("text-slate-600 dark:text-slate-300")
|
|
1642
|
+
)
|
|
1643
|
+
|
|
1228
1644
|
# Workflow basic info card (full width at top)
|
|
1229
|
-
with ui.card().classes("w-full mb-4"):
|
|
1230
|
-
ui.label(instance["workflow_name"]).classes(
|
|
1645
|
+
with ui.card().classes(f"w-full mb-4 {TAILWIND_CLASSES['card']}"):
|
|
1646
|
+
ui.label(instance["workflow_name"]).classes(
|
|
1647
|
+
f"text-2xl font-bold {TAILWIND_CLASSES['text_primary']}"
|
|
1648
|
+
)
|
|
1231
1649
|
|
|
1232
1650
|
with ui.row().classes("gap-4 items-center flex-wrap"):
|
|
1233
1651
|
status = instance["status"]
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
ui.label(f"Updated: {instance['updated_at']}").classes("text-sm text-gray-600")
|
|
1652
|
+
status_labels = {
|
|
1653
|
+
"completed": "✅ Completed",
|
|
1654
|
+
"running": "⏳ Running",
|
|
1655
|
+
"failed": "❌ Failed",
|
|
1656
|
+
"waiting_for_event": "⏸️ Waiting (Event)",
|
|
1657
|
+
"waiting_for_timer": "⏱️ Waiting (Timer)",
|
|
1658
|
+
"cancelled": "🚫 Cancelled",
|
|
1659
|
+
"compensating": "🔄 Compensating",
|
|
1660
|
+
}
|
|
1661
|
+
label_text = status_labels.get(status, status)
|
|
1662
|
+
ui.label(label_text).classes(get_status_badge_classes(status))
|
|
1663
|
+
|
|
1664
|
+
ui.label(f"Started: {instance['started_at']}").classes(
|
|
1665
|
+
f"text-sm {TAILWIND_CLASSES['text_secondary']}"
|
|
1666
|
+
)
|
|
1667
|
+
ui.label(f"Updated: {instance['updated_at']}").classes(
|
|
1668
|
+
f"text-sm {TAILWIND_CLASSES['text_secondary']}"
|
|
1669
|
+
)
|
|
1253
1670
|
|
|
1254
|
-
ui.label(f"Instance ID: {instance_id}").classes(
|
|
1671
|
+
ui.label(f"Instance ID: {instance_id}").classes(
|
|
1672
|
+
f"text-xs {TAILWIND_CLASSES['text_muted']} font-mono mt-2"
|
|
1673
|
+
)
|
|
1255
1674
|
|
|
1256
1675
|
# Input Parameters section
|
|
1257
1676
|
input_data = instance.get("input_data")
|
|
@@ -1341,9 +1760,9 @@ def start_viewer(edda_app: EddaApp, port: int = 8080, reload: bool = False) -> N
|
|
|
1341
1760
|
with ui.row().style("width: 100%; height: calc(100vh - 250px); gap: 1rem; display: flex;"):
|
|
1342
1761
|
# Left pane: Execution Flow
|
|
1343
1762
|
with ui.column().style("flex: 1; overflow: auto; padding-right: 1rem;"):
|
|
1344
|
-
ui.markdown("## Execution Flow")
|
|
1763
|
+
ui.markdown("## Execution Flow").classes(TAILWIND_CLASSES["text_primary"])
|
|
1345
1764
|
ui.label("Click on an activity to view details →").classes(
|
|
1346
|
-
"text-
|
|
1765
|
+
f"text-sm mb-2 {TAILWIND_CLASSES['text_secondary']}"
|
|
1347
1766
|
)
|
|
1348
1767
|
|
|
1349
1768
|
if history:
|
|
@@ -1374,15 +1793,17 @@ def start_viewer(edda_app: EddaApp, port: int = 8080, reload: bool = False) -> N
|
|
|
1374
1793
|
|
|
1375
1794
|
ui.mermaid(mermaid_code, config={"securityLevel": "loose"}).classes("w-full")
|
|
1376
1795
|
else:
|
|
1377
|
-
ui.label("No execution history available").classes(
|
|
1796
|
+
ui.label("No execution history available").classes(
|
|
1797
|
+
f"{TAILWIND_CLASSES['text_muted']} italic"
|
|
1798
|
+
)
|
|
1378
1799
|
|
|
1379
1800
|
# Right pane: Activity Details
|
|
1380
|
-
with ui.column().
|
|
1381
|
-
"flex
|
|
1801
|
+
with ui.column().classes(
|
|
1802
|
+
f"flex-1 overflow-auto p-4 rounded-lg {TAILWIND_CLASSES['surface']} {TAILWIND_CLASSES['border']} border-l-2"
|
|
1382
1803
|
):
|
|
1383
|
-
ui.markdown("## Activity Details")
|
|
1804
|
+
ui.markdown("## Activity Details").classes(TAILWIND_CLASSES["text_primary"])
|
|
1384
1805
|
ui.label("Click on an activity in the diagram to view details").classes(
|
|
1385
|
-
"
|
|
1806
|
+
f"{TAILWIND_CLASSES['text_muted']} italic mb-4"
|
|
1386
1807
|
)
|
|
1387
1808
|
|
|
1388
1809
|
detail_container = ui.column().classes("w-full")
|
|
@@ -1396,4 +1817,9 @@ def start_viewer(edda_app: EddaApp, port: int = 8080, reload: bool = False) -> N
|
|
|
1396
1817
|
app.on_shutdown(shutdown_handler)
|
|
1397
1818
|
|
|
1398
1819
|
# Start server
|
|
1399
|
-
ui.run(
|
|
1820
|
+
ui.run(
|
|
1821
|
+
port=port,
|
|
1822
|
+
title="Edda Workflow Viewer",
|
|
1823
|
+
reload=reload,
|
|
1824
|
+
storage_secret="edda_viewer_secret",
|
|
1825
|
+
)
|