Adding error management

This commit is contained in:
2025-07-11 18:34:04 +02:00
parent 03ed1af7e6
commit 2b288348e2
6 changed files with 83 additions and 37 deletions

View File

@@ -51,12 +51,17 @@
.wkf-canvas { .wkf-canvas {
position: relative; position: relative;
box-sizing: border-box;
background-image: background-image:
linear-gradient(rgba(0,0,0,.1) 1px, transparent 1px), linear-gradient(rgba(0,0,0,.1) 1px, transparent 1px),
linear-gradient(90deg, rgba(0,0,0,.1) 1px, transparent 1px); linear-gradient(90deg, rgba(0,0,0,.1) 1px, transparent 1px);
background-size: 20px 20px; background-size: 20px 20px;
} }
.wkf-canvas-error {
border: 3px solid var(--color-error);
}
.wkf-toolbox { .wkf-toolbox {
min-height: 230px; min-height: 230px;
width: 8rem; /* w-32 (32 * 0.25rem = 8rem) */ width: 8rem; /* w-32 (32 * 0.25rem = 8rem) */
@@ -89,6 +94,10 @@
transition: none; transition: none;
} }
.wkf-workflow-component.error {
background: var(--color-error);
}
.wkf-component-content { .wkf-component-content {
padding: 0.75rem; /* p-3 in Tailwind */ padding: 0.75rem; /* p-3 in Tailwind */
border-radius: 0.5rem; /* rounded-lg in Tailwind */ border-radius: 0.5rem; /* rounded-lg in Tailwind */

View File

@@ -11,7 +11,7 @@ from components.workflows.commands import WorkflowDesignerCommandManager
from components.workflows.components.WorkflowPlayer import WorkflowPlayer from components.workflows.components.WorkflowPlayer import WorkflowPlayer
from components.workflows.constants import WORKFLOW_DESIGNER_INSTANCE_ID, ProcessorTypes from components.workflows.constants import WORKFLOW_DESIGNER_INSTANCE_ID, ProcessorTypes
from components.workflows.db_management import WorkflowsDesignerSettings, WorkflowComponent, \ from components.workflows.db_management import WorkflowsDesignerSettings, WorkflowComponent, \
Connection, WorkflowsDesignerDbManager, WorkflowsPlayerSettings Connection, WorkflowsDesignerDbManager, WorkflowsPlayerSettings, WorkflowComponentRuntimeState
from components_helpers import apply_boundaries, mk_tooltip, mk_dialog_buttons, mk_icon from components_helpers import apply_boundaries, mk_tooltip, mk_dialog_buttons, mk_icon
from core.instance_manager import InstanceManager from core.instance_manager import InstanceManager
from core.utils import get_unique_id, make_safe_id from core.utils import get_unique_id, make_safe_id
@@ -64,6 +64,17 @@ class WorkflowDesigner(BaseComponent):
self._state = self._db.load_state(key) self._state = self._db.load_state(key)
self._boundaries = boundaries self._boundaries = boundaries
self.commands = WorkflowDesignerCommandManager(self) self.commands = WorkflowDesignerCommandManager(self)
workflow_name = self._designer_settings.workflow_name
self._player = InstanceManager.get(self._session,
WorkflowPlayer.create_component_id(self._session, workflow_name),
WorkflowPlayer,
settings_manager=self._settings_manager,
tabs_manager=self.tabs_manager,
player_settings=WorkflowsPlayerSettings(workflow_name,
list(self._state.components.values())),
boundaries=boundaries)
self._error_message = None self._error_message = None
def set_boundaries(self, boundaries: dict): def set_boundaries(self, boundaries: dict):
@@ -178,24 +189,17 @@ class WorkflowDesigner(BaseComponent):
def play_workflow(self, boundaries: dict): def play_workflow(self, boundaries: dict):
if self._state.selected_component_id is None: if self._state.selected_component_id is None:
return self.error_message("No component selected") self._error_message = "No component selected"
workflow_name = self._designer_settings.workflow_name
player = InstanceManager.get(self._session,
WorkflowPlayer.create_component_id(self._session, workflow_name),
WorkflowPlayer,
settings_manager=self._settings_manager,
tabs_manager=self.tabs_manager,
player_settings=WorkflowsPlayerSettings(workflow_name,
list(self._state.components.values())),
boundaries=boundaries)
try:
player.run()
self.tabs_manager.add_tab(f"Workflow {workflow_name}", player, player.key)
return self.tabs_manager.refresh() return self.tabs_manager.refresh()
try:
self._player.run()
self.tabs_manager.add_tab(f"Workflow {self._designer_settings.workflow_name}", self._player, self._player.key)
except Exception as e: except Exception as e:
return self.error_message(str(e)) self._error_message = str(e)
return self.tabs_manager.refresh()
def on_processor_details_event(self, component_id: str, event_name: str, details: dict): def on_processor_details_event(self, component_id: str, event_name: str, details: dict):
if component_id in self._state.components: if component_id in self._state.components:
@@ -207,10 +211,6 @@ class WorkflowDesigner(BaseComponent):
return self.refresh_properties() return self.refresh_properties()
def error_message(self, message: str):
self._error_message = message
return self.tabs_manager.refresh()
def __ft__(self): def __ft__(self):
return Div( return Div(
H1(f"{self._designer_settings.workflow_name}", cls="text-xl font-bold"), H1(f"{self._designer_settings.workflow_name}", cls="text-xl font-bold"),
@@ -265,13 +265,14 @@ class WorkflowDesigner(BaseComponent):
*[NotStr(self._mk_connection_svg(conn)) for conn in self._state.connections], *[NotStr(self._mk_connection_svg(conn)) for conn in self._state.connections],
# Render components # Render components
*[self._mk_workflow_component(comp) for comp in self._state.components.values()], *[self._mk_workflow_component(comp, state) for comp, state in zip(self._state.components.values(),
self._player.runtime_states)],
) )
def _mk_canvas(self, oob=False): def _mk_canvas(self, oob=False):
return Div( return Div(
self._mk_elements(), self._mk_elements(),
cls="wkf-canvas flex-1 rounded-lg border flex-1", cls=f"wkf-canvas flex-1 rounded-lg border flex-1 {'wkf-canvas-error' if self._error_message else ''}",
id=f"c_{self._id}", id=f"c_{self._id}",
hx_swap_oob='true' if oob else None, hx_swap_oob='true' if oob else None,
), ),
@@ -503,7 +504,7 @@ class WorkflowDesigner(BaseComponent):
) )
@staticmethod @staticmethod
def _mk_workflow_component(component: WorkflowComponent): def _mk_workflow_component(component: WorkflowComponent, component_state: WorkflowComponentRuntimeState):
info = COMPONENT_TYPES[component.type] info = COMPONENT_TYPES[component.type]
return Div( return Div(
# Input connection point # Input connection point
@@ -515,7 +516,7 @@ class WorkflowDesigner(BaseComponent):
Div( Div(
Span(info["icon"], cls="text-xl mb-1"), Span(info["icon"], cls="text-xl mb-1"),
H4(component.title, cls="font-semibold text-xs"), H4(component.title, cls="font-semibold text-xs"),
cls=f"wkf-component-content {info['color']}" cls=f"wkf-component-content {info['color']} {'error' if component_state.has_error else ''}"
), ),
# Output connection point # Output connection point

View File

@@ -6,7 +6,7 @@ from components.datagrid_new.components.DataGrid import DataGrid
from components.datagrid_new.settings import DataGridSettings from components.datagrid_new.settings import DataGridSettings
from components.workflows.commands import WorkflowPlayerCommandManager from components.workflows.commands import WorkflowPlayerCommandManager
from components.workflows.constants import WORKFLOW_PLAYER_INSTANCE_ID, ProcessorTypes from components.workflows.constants import WORKFLOW_PLAYER_INSTANCE_ID, ProcessorTypes
from components.workflows.db_management import WorkflowsPlayerSettings from components.workflows.db_management import WorkflowsPlayerSettings, WorkflowComponentRuntimeState
from core.instance_manager import InstanceManager from core.instance_manager import InstanceManager
from core.utils import get_unique_id, make_safe_id from core.utils import get_unique_id, make_safe_id
from workflow.engine import WorkflowEngine, TableDataProducer, DefaultDataPresenter, DefaultDataFilter from workflow.engine import WorkflowEngine, TableDataProducer, DefaultDataPresenter, DefaultDataFilter
@@ -39,21 +39,38 @@ class WorkflowPlayer(BaseComponent):
key=self.key, key=self.key,
grid_settings=grid_settings, grid_settings=grid_settings,
boundaries=boundaries) boundaries=boundaries)
self.runtime_states = [WorkflowComponentRuntimeState(component.id) for component in player_settings.components]
self.global_error = False
def run(self): def run(self):
engine = WorkflowEngine() engine = WorkflowEngine()
for component in self._player_settings.components: for component in self._player_settings.components:
if component.type == ProcessorTypes.Producer and component.properties["processor_name"] == "Repository": if component.type == ProcessorTypes.Producer and component.properties["processor_name"] == "Repository":
engine.add_processor( engine.add_processor(
TableDataProducer(self._session, self._settings_manager, component.properties["repository"], TableDataProducer(self._session,
self._settings_manager,
component.id,
component.properties["repository"],
component.properties["table"])) component.properties["table"]))
elif component.type == ProcessorTypes.Filter and component.properties["processor_name"] == "Default": elif component.type == ProcessorTypes.Filter and component.properties["processor_name"] == "Default":
engine.add_processor(DefaultDataFilter(component.properties["filter"])) engine.add_processor(DefaultDataFilter(component.id, component.properties["filter"]))
elif component.type == ProcessorTypes.Presenter and component.properties["processor_name"] == "Default": elif component.type == ProcessorTypes.Presenter and component.properties["processor_name"] == "Default":
engine.add_processor(DefaultDataPresenter(component.properties["columns"])) engine.add_processor(DefaultDataPresenter(component.id, component.properties["columns"]))
res = engine.run_to_list() res = engine.run_to_list()
if engine.has_error:
self.global_error = engine.global_error
for runtime_state in self.runtime_states:
if runtime_state.id in engine.errors:
runtime_state.has_error = True
runtime_state.error_message = engine.errors[runtime_state.id].error_message
else:
runtime_state.has_error = False
runtime_state.error_message = ""
data = [row.as_dict() for row in res] data = [row.as_dict() for row in res]
df = pd.DataFrame(data) df = pd.DataFrame(data)
self._datagrid.init_from_dataframe(df) self._datagrid.init_from_dataframe(df)

View File

@@ -27,6 +27,12 @@ class Connection:
to_id: str to_id: str
@dataclass
class WorkflowComponentRuntimeState:
id: str
has_error: bool = False
error_message: str = ""
@dataclass @dataclass
class WorkflowsDesignerSettings: class WorkflowsDesignerSettings:
workflow_name: str = "No Name" workflow_name: str = "No Name"

View File

@@ -10,6 +10,9 @@ from utils.Datahelper import DataHelper
class DataProcessor(ABC): class DataProcessor(ABC):
"""Base class for all data processing components.""" """Base class for all data processing components."""
def __init__(self, component_id: str = None):
self.component_id = component_id
@abstractmethod @abstractmethod
def process(self, data: Any) -> Generator[Any, None, None]: def process(self, data: Any) -> Generator[Any, None, None]:
pass pass
@@ -55,7 +58,8 @@ class DataPresenter(DataProcessor):
class TableDataProducer(DataProducer): class TableDataProducer(DataProducer):
"""Base class for data producers that emit data from a repository.""" """Base class for data producers that emit data from a repository."""
def __init__(self, session, settings_manager, repository_name, table_name): def __init__(self, session, settings_manager, component_id, repository_name, table_name):
super().__init__(component_id)
self._session = session self._session = session
self.settings_manager = settings_manager self.settings_manager = settings_manager
self.repository_name = repository_name self.repository_name = repository_name
@@ -68,8 +72,8 @@ class TableDataProducer(DataProducer):
class DefaultDataPresenter(DataPresenter): class DefaultDataPresenter(DataPresenter):
"""Default data presenter that returns the input data unchanged.""" """Default data presenter that returns the input data unchanged."""
def __init__(self, columns_as_str: str): def __init__(self, component_id: str, columns_as_str: str):
super().__init__() super().__init__(component_id)
if not columns_as_str or columns_as_str == "*": if not columns_as_str or columns_as_str == "*":
self.mappings = None self.mappings = None
@@ -92,8 +96,8 @@ class DefaultDataPresenter(DataPresenter):
class DefaultDataFilter(DataFilter): class DefaultDataFilter(DataFilter):
def __init__(self, filter_expression: str): def __init__(self, component_id: str, filter_expression: str):
super().__init__() super().__init__(component_id)
self.filter_expression = filter_expression self.filter_expression = filter_expression
self._ast_tree = ast.parse(filter_expression, "<user input>", 'eval') self._ast_tree = ast.parse(filter_expression, "<user input>", 'eval')
self._compiled = compile(self._ast_tree, "<string>", "eval") self._compiled = compile(self._ast_tree, "<string>", "eval")
@@ -112,6 +116,9 @@ class WorkflowEngine:
def __init__(self): def __init__(self):
self.processors: list[DataProcessor] = [] self.processors: list[DataProcessor] = []
self.has_error = False
self.global_error = None
self.errors = {}
def add_processor(self, processor: DataProcessor) -> 'WorkflowEngine': def add_processor(self, processor: DataProcessor) -> 'WorkflowEngine':
"""Add a data processor to the pipeline.""" """Add a data processor to the pipeline."""
@@ -137,12 +144,16 @@ class WorkflowEngine:
The first processor must be a DataProducer. The first processor must be a DataProducer.
""" """
if not self.processors: if not self.processors:
raise ValueError("No processors in the pipeline") self.has_error = False
self.global_error = "No processors in the pipeline"
raise ValueError(self.global_error)
first_processor = self.processors[0] first_processor = self.processors[0]
if not isinstance(first_processor, DataProducer): if not isinstance(first_processor, DataProducer):
raise ValueError("First processor must be a DataProducer") self.has_error = False
self.global_error = "First processor must be a DataProducer"
raise ValueError(self.global_error)
for item in first_processor.emit(): for item in first_processor.emit():
yield from self._process_single_item(item, 1) yield from self._process_single_item(item, 1)

View File

@@ -5,7 +5,8 @@ from fasthtml.xtend import Script
from components.workflows.components.WorkflowDesigner import WorkflowDesigner, COMPONENT_TYPES from components.workflows.components.WorkflowDesigner import WorkflowDesigner, COMPONENT_TYPES
from components.workflows.constants import ProcessorTypes from components.workflows.constants import ProcessorTypes
from components.workflows.db_management import WorkflowsDesignerSettings, WorkflowComponent, Connection from components.workflows.db_management import WorkflowsDesignerSettings, WorkflowComponent, Connection, \
WorkflowComponentRuntimeState
from core.settings_management import SettingsManager, MemoryDbEngine from core.settings_management import SettingsManager, MemoryDbEngine
from helpers import matches, Contains from helpers import matches, Contains
@@ -83,7 +84,8 @@ def test_i_can_render_no_component(designer):
def test_i_can_render_a_producer(designer, producer_component): def test_i_can_render_a_producer(designer, producer_component):
component = producer_component component = producer_component
actual = designer._mk_workflow_component(component) component_state = WorkflowComponentRuntimeState(component.id)
actual = designer._mk_workflow_component(component, component_state)
expected = Div( expected = Div(
# input connection point # input connection point
Div(cls="wkf-connection-point wkf-input-point", Div(cls="wkf-connection-point wkf-input-point",