From bdd954b2439c83e279613315ed7ce9e8bb7f0cb9 Mon Sep 17 00:00:00 2001 From: Kodjo Sossouvi Date: Sat, 12 Jul 2025 17:45:30 +0200 Subject: [PATCH] Improving error management --- src/assets/main.js | 5 ++ src/components/workflows/WorkflowsApp.py | 9 ++- src/components/workflows/assets/Workflows.css | 1 - .../workflows/components/WorkflowDesigner.py | 23 ++++-- .../workflows/components/WorkflowPlayer.py | 73 +++++++++++++------ 5 files changed, 81 insertions(+), 30 deletions(-) diff --git a/src/assets/main.js b/src/assets/main.js index 688b733..33e777d 100644 --- a/src/assets/main.js +++ b/src/assets/main.js @@ -1,6 +1,11 @@ const tooltipElementId = "mmt-app" function bindTooltipsWithDelegation() { + // To display the tooltip, the attribute 'data-tooltip' is mandatory => it contains the text to tooltip + // Then + // the 'truncate' to show only when the text is truncated + // the class 'mmt-tooltip' for force the display + const elementId = tooltipElementId console.debug("bindTooltips on element " + elementId); diff --git a/src/components/workflows/WorkflowsApp.py b/src/components/workflows/WorkflowsApp.py index 518e0f3..701dd83 100644 --- a/src/components/workflows/WorkflowsApp.py +++ b/src/components/workflows/WorkflowsApp.py @@ -43,7 +43,7 @@ def post(session, _id: str, component_type: str, x: int, y: int): @rt(Routes.MoveComponent) -def post(session, _id: str, component_id: str, x: int, y: int): +def post(session, _id: str, component_id: str, x: float, y: float): logger.debug( f"Entering {Routes.MoveComponent} with args {debug_session(session)}, {_id=}, {component_id=}, {x=}, {y=}") instance = InstanceManager.get(session, _id) @@ -133,3 +133,10 @@ def post(session, _id: str, tab_boundaries: str): f"Entering {Routes.PlayWorkflow} with args {debug_session(session)}, {_id=}") instance = InstanceManager.get(session, _id) return instance.play_workflow(json.loads(tab_boundaries)) + +@rt(Routes.StopWorkflow) +def post(session, _id: str, tab_boundaries: str): + logger.debug( + f"Entering {Routes.StopWorkflow} with args {debug_session(session)}, {_id=}") + instance = InstanceManager.get(session, _id) + return instance.stop_workflow(json.loads(tab_boundaries)) \ No newline at end of file diff --git a/src/components/workflows/assets/Workflows.css b/src/components/workflows/assets/Workflows.css index a1e909f..2761e8c 100644 --- a/src/components/workflows/assets/Workflows.css +++ b/src/components/workflows/assets/Workflows.css @@ -114,7 +114,6 @@ } .wkf-component-content.not-run { - background: var(--color-neutral); } .wkf-connection-line { diff --git a/src/components/workflows/components/WorkflowDesigner.py b/src/components/workflows/components/WorkflowDesigner.py index 80ce9b6..8522751 100644 --- a/src/components/workflows/components/WorkflowDesigner.py +++ b/src/components/workflows/components/WorkflowDesigner.py @@ -198,12 +198,18 @@ class WorkflowDesigner(BaseComponent): self._error_message = self._player.global_error else: - + # change the tab and display the results + self._player.set_boundaries(boundaries) self.tabs_manager.add_tab(f"Workflow {self._designer_settings.workflow_name}", self._player, self._player.key) return self.tabs_manager.refresh() + def stop_workflow(self, boundaries): + self._error_message = None + self._player.run() + return self.tabs_manager.refresh() + def on_processor_details_event(self, component_id: str, event_name: str, details: dict): if component_id in self._state.components: component = self._state.components[component_id] @@ -271,6 +277,12 @@ class WorkflowDesigner(BaseComponent): state_class = 'not-run' # To be styled as greyed-out else: state_class = '' + if runtime_state.state == ComponentState.FAILURE: + tooltip_content = runtime_state.error_message + tooltip_class = "mmt-tooltip" + else: + tooltip_content = None + tooltip_class = "" return Div( # Input connection point @@ -290,9 +302,10 @@ class WorkflowDesigner(BaseComponent): data_component_id=component.id, data_point_type="output"), - cls=f"wkf-workflow-component w-32 {'selected' if is_selected else ''}", + cls=f"wkf-workflow-component w-32 {'selected' if is_selected else ''} {tooltip_class}", style=f"left: {component.x}px; top: {component.y}px;", data_component_id=component.id, + data_tooltip=tooltip_content, draggable="true" ) @@ -303,7 +316,7 @@ class WorkflowDesigner(BaseComponent): # Render components *[self._mk_component(comp, state) for comp, state in zip(self._state.components.values(), - self._player.runtime_states)], + self._player.runtime_states.values())], ) def _mk_canvas(self, oob=False): @@ -337,8 +350,8 @@ class WorkflowDesigner(BaseComponent): def _mk_media(self): return Div( mk_icon(icon_play, cls="mr-1", **self.commands.play_workflow()), - mk_icon(icon_pause, cls="mr-1", **self.commands.play_workflow()), - mk_icon(icon_stop, cls="mr-1", **self.commands.play_workflow()), + mk_icon(icon_pause, cls="mr-1", **self.commands.pause_workflow()), + mk_icon(icon_stop, cls="mr-1", **self.commands.stop_workflow()), cls=f"media-controls flex m-2" ) diff --git a/src/components/workflows/components/WorkflowPlayer.py b/src/components/workflows/components/WorkflowPlayer.py index cdc25bc..e7b8e52 100644 --- a/src/components/workflows/components/WorkflowPlayer.py +++ b/src/components/workflows/components/WorkflowPlayer.py @@ -1,4 +1,5 @@ from collections import deque +from dataclasses import dataclass import pandas as pd from fasthtml.components import * @@ -22,6 +23,12 @@ grid_settings = DataGridSettings( open_settings_visible=False) +@dataclass +class WorkflowsPlayerError(Exception): + component_id: str + error: Exception + + class WorkflowPlayer(BaseComponent): def __init__(self, session, _id=None, @@ -42,16 +49,16 @@ class WorkflowPlayer(BaseComponent): key=self.key, grid_settings=grid_settings, boundaries=boundaries) - self.runtime_states = [WorkflowComponentRuntimeState(component.id) for component in player_settings.components] + self.runtime_states = {component.id: WorkflowComponentRuntimeState(component.id) + for component in player_settings.components} self.global_error = None self.has_error = False + def set_boundaries(self, boundaries: dict): + self._datagrid.set_boundaries(boundaries) + def run(self): - # Reset all component states to NOT_RUN before execution - for state in self.runtime_states: - state.state = ComponentState.NOT_RUN - state.error_message = None - self.global_error = None + self._reset_state(state=ComponentState.NOT_RUN) components_by_id = {c.id: c for c in self._player_settings.components} @@ -63,10 +70,16 @@ class WorkflowPlayer(BaseComponent): self._datagrid.init_from_dataframe(pd.DataFrame([])) return - engine = self._get_engine() - res = engine.run_to_list() + try: + engine = self._get_engine() + except WorkflowsPlayerError as ex: + if ex.component_id in self.runtime_states: + self.runtime_states[ex.component_id].state = ComponentState.FAILURE + self.runtime_states[ex.component_id].error_message = str(ex.error) + self.global_error = f"Failed to init component '{ex.component_id}': {ex.error}" + return - runtime_states_by_id = {rs.id: rs for rs in self.runtime_states} + res = engine.run_to_list() if engine.has_error: self.has_error = True @@ -78,7 +91,7 @@ class WorkflowPlayer(BaseComponent): # Determine component states by simulating a "stop-on-fail" execution first_failure_found = False for component in sorted_components: - runtime_state = runtime_states_by_id.get(component.id) + runtime_state = self.runtime_states.get(component.id) if not runtime_state: continue @@ -108,6 +121,9 @@ class WorkflowPlayer(BaseComponent): df = pd.DataFrame(data) self._datagrid.init_from_dataframe(df) + def stop(self): + self._reset_state() + def __ft__(self): return Div( self._datagrid, @@ -172,21 +188,32 @@ class WorkflowPlayer(BaseComponent): sorted_components = self._get_sorted_components() engine = WorkflowEngine() for component in sorted_components: - if component.type == ProcessorTypes.Producer and component.properties["processor_name"] == "Repository": - engine.add_processor( - TableDataProducer(self._session, - self._settings_manager, - component.id, - component.properties["repository"], - component.properties["table"])) - - elif component.type == ProcessorTypes.Filter and component.properties["processor_name"] == "Default": - engine.add_processor(DefaultDataFilter(component.id, component.properties["filter"])) - - elif component.type == ProcessorTypes.Presenter and component.properties["processor_name"] == "Default": - engine.add_processor(DefaultDataPresenter(component.id, component.properties["columns"])) + try: + if component.type == ProcessorTypes.Producer and component.properties["processor_name"] == "Repository": + engine.add_processor( + TableDataProducer(self._session, + self._settings_manager, + component.id, + component.properties["repository"], + component.properties["table"])) + + elif component.type == ProcessorTypes.Filter and component.properties["processor_name"] == "Default": + engine.add_processor(DefaultDataFilter(component.id, component.properties["filter"])) + + elif component.type == ProcessorTypes.Presenter and component.properties["processor_name"] == "Default": + engine.add_processor(DefaultDataPresenter(component.id, component.properties["columns"])) + except Exception as e: + raise WorkflowsPlayerError(component.id, e) + return engine + def _reset_state(self, state: ComponentState = ComponentState.SUCCESS): + self.global_error = None + self.has_error = False + for runtime_state in self.runtime_states.values(): + runtime_state.state = state + runtime_state.error_message = None + @staticmethod def create_component_id(session, suffix=None): prefix = f"{WORKFLOW_PLAYER_INSTANCE_ID}{session['user_id']}"