edda-framework 0.6.0__py3-none-any.whl → 0.8.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
edda/viewer_ui/app.py 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
- 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"):
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 bg-gray-50 p-4"):
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("w-full bg-red-50 border-red-200 p-4"):
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("text-red-700 font-bold")
70
- ui.label(detail["error"]).classes("text-red-700 font-mono text-sm mt-2")
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(f"{activity_name}: {detail['activity_id']}").classes(
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 {activity_name}: {activity_id}", type="positive")
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
- # Header with title and start button
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
- # 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")
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
- # Get all available workflows
165
- all_workflows = service.get_all_workflows()
166
- workflow_names = list(all_workflows.keys())
308
+ # Get all available workflows
309
+ all_workflows = service.get_all_workflows()
310
+ workflow_names = list(all_workflows.keys())
167
311
 
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")
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
- # Container for dynamic parameter fields
180
- params_container = ui.column().classes("w-full mb-4")
323
+ # Container for dynamic parameter fields
324
+ params_container = ui.column().classes("w-full mb-4")
181
325
 
182
- # Store input field references
183
- param_fields: dict[str, Any] = {}
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,77 +1267,345 @@ 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
- ui.button(
1127
- "Start New Workflow",
1128
- on_click=start_dialog.open,
1129
- icon="play_arrow",
1130
- color="positive",
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
- ui.label("Click on an instance to view execution details").classes("text-gray-600 mb-4")
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
- instances = await service.get_all_instances(limit=100)
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
- 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"
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
- 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")
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("text-red-500 text-xl mt-8")
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-2"):
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
- if status in ["running", "waiting_for_event", "waiting_for_timer"]:
1603
+ if status in [
1604
+ "running",
1605
+ "waiting_for_event",
1606
+ "waiting_for_timer",
1607
+ "waiting_for_message",
1608
+ ]:
1197
1609
 
1198
1610
  async def handle_cancel() -> None:
1199
1611
  """Handle workflow cancellation."""
@@ -1225,33 +1637,45 @@ def start_viewer(edda_app: EddaApp, port: int = 8080, reload: bool = False) -> N
1225
1637
 
1226
1638
  ui.button("← Back to List", on_click=lambda: ui.navigate.to("/")).props("flat")
1227
1639
 
1640
+ # Theme toggle button (rightmost)
1641
+ is_dark = app.storage.user.get("dark_mode", False)
1642
+ icon = "light_mode" if is_dark else "dark_mode"
1643
+ theme_button = (
1644
+ ui.button(on_click=toggle_theme)
1645
+ .props(f"icon={icon} flat round")
1646
+ .classes("text-slate-600 dark:text-slate-300")
1647
+ )
1648
+
1228
1649
  # 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")
1650
+ with ui.card().classes(f"w-full mb-4 {TAILWIND_CLASSES['card']}"):
1651
+ ui.label(instance["workflow_name"]).classes(
1652
+ f"text-2xl font-bold {TAILWIND_CLASSES['text_primary']}"
1653
+ )
1231
1654
 
1232
1655
  with ui.row().classes("gap-4 items-center flex-wrap"):
1233
1656
  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")
1657
+ status_labels = {
1658
+ "completed": "✅ Completed",
1659
+ "running": "⏳ Running",
1660
+ "failed": " Failed",
1661
+ "waiting_for_event": "⏸️ Waiting (Event)",
1662
+ "waiting_for_timer": "⏱️ Waiting (Timer)",
1663
+ "cancelled": "🚫 Cancelled",
1664
+ "compensating": "🔄 Compensating",
1665
+ }
1666
+ label_text = status_labels.get(status, status)
1667
+ ui.label(label_text).classes(get_status_badge_classes(status))
1668
+
1669
+ ui.label(f"Started: {instance['started_at']}").classes(
1670
+ f"text-sm {TAILWIND_CLASSES['text_secondary']}"
1671
+ )
1672
+ ui.label(f"Updated: {instance['updated_at']}").classes(
1673
+ f"text-sm {TAILWIND_CLASSES['text_secondary']}"
1674
+ )
1253
1675
 
1254
- ui.label(f"Instance ID: {instance_id}").classes("text-xs text-gray-500 font-mono mt-2")
1676
+ ui.label(f"Instance ID: {instance_id}").classes(
1677
+ f"text-xs {TAILWIND_CLASSES['text_muted']} font-mono mt-2"
1678
+ )
1255
1679
 
1256
1680
  # Input Parameters section
1257
1681
  input_data = instance.get("input_data")
@@ -1341,9 +1765,9 @@ def start_viewer(edda_app: EddaApp, port: int = 8080, reload: bool = False) -> N
1341
1765
  with ui.row().style("width: 100%; height: calc(100vh - 250px); gap: 1rem; display: flex;"):
1342
1766
  # Left pane: Execution Flow
1343
1767
  with ui.column().style("flex: 1; overflow: auto; padding-right: 1rem;"):
1344
- ui.markdown("## Execution Flow")
1768
+ ui.markdown("## Execution Flow").classes(TAILWIND_CLASSES["text_primary"])
1345
1769
  ui.label("Click on an activity to view details →").classes(
1346
- "text-gray-600 text-sm mb-2"
1770
+ f"text-sm mb-2 {TAILWIND_CLASSES['text_secondary']}"
1347
1771
  )
1348
1772
 
1349
1773
  if history:
@@ -1374,15 +1798,17 @@ def start_viewer(edda_app: EddaApp, port: int = 8080, reload: bool = False) -> N
1374
1798
 
1375
1799
  ui.mermaid(mermaid_code, config={"securityLevel": "loose"}).classes("w-full")
1376
1800
  else:
1377
- ui.label("No execution history available").classes("text-gray-500 italic")
1801
+ ui.label("No execution history available").classes(
1802
+ f"{TAILWIND_CLASSES['text_muted']} italic"
1803
+ )
1378
1804
 
1379
1805
  # 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;"
1806
+ with ui.column().classes(
1807
+ f"flex-1 overflow-auto p-4 rounded-lg {TAILWIND_CLASSES['surface']} {TAILWIND_CLASSES['border']} border-l-2"
1382
1808
  ):
1383
- ui.markdown("## Activity Details")
1809
+ ui.markdown("## Activity Details").classes(TAILWIND_CLASSES["text_primary"])
1384
1810
  ui.label("Click on an activity in the diagram to view details").classes(
1385
- "text-gray-500 italic mb-4"
1811
+ f"{TAILWIND_CLASSES['text_muted']} italic mb-4"
1386
1812
  )
1387
1813
 
1388
1814
  detail_container = ui.column().classes("w-full")
@@ -1396,4 +1822,9 @@ def start_viewer(edda_app: EddaApp, port: int = 8080, reload: bool = False) -> N
1396
1822
  app.on_shutdown(shutdown_handler)
1397
1823
 
1398
1824
  # Start server
1399
- ui.run(port=port, title="Edda Workflow Viewer", reload=reload)
1825
+ ui.run(
1826
+ port=port,
1827
+ title="Edda Workflow Viewer",
1828
+ reload=reload,
1829
+ storage_secret="edda_viewer_secret",
1830
+ )