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/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,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
- 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
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("text-2xl font-bold")
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
- 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")
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("text-xs text-gray-500 font-mono mt-2")
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-gray-600 text-sm mb-2"
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("text-gray-500 italic")
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().style(
1381
- "flex: 1; overflow: auto; padding: 1rem; background: #f9fafb; border-left: 2px solid #e5e7eb; border-radius: 0.5rem;"
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
- "text-gray-500 italic mb-4"
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(port=port, title="Edda Workflow Viewer", reload=reload)
1820
+ ui.run(
1821
+ port=port,
1822
+ title="Edda Workflow Viewer",
1823
+ reload=reload,
1824
+ storage_secret="edda_viewer_secret",
1825
+ )