diff --git a/backend/backend/application/file_explorer/file_explorer.py b/backend/backend/application/file_explorer/file_explorer.py index 79402ba..e3a461c 100644 --- a/backend/backend/application/file_explorer/file_explorer.py +++ b/backend/backend/application/file_explorer/file_explorer.py @@ -93,18 +93,25 @@ def load_models(self, session: Session): # Sort models by execution order (DAG order) sorted_model_names = topological_sort_models(models_with_refs) + # Build a lookup from model name -> model object for status fields + model_lookup = {m.model_name: m for m in all_models} + # Build the model structure in sorted order no_code_model_structure = [] for no_code_model_name in sorted_model_names: - no_code_model_structure.append( - { - "extension": no_code_model_name, - "title": no_code_model_name, - "key": f"{self.project_name}/models/no_code/{no_code_model_name}", - "is_folder": False, - "type": "NO_CODE_MODEL", - } - ) + model = model_lookup.get(no_code_model_name) + model_data = { + "extension": no_code_model_name, + "title": no_code_model_name, + "key": f"{self.project_name}/models/no_code/{no_code_model_name}", + "is_folder": False, + "type": "NO_CODE_MODEL", + "run_status": getattr(model, "run_status", None), + "failure_reason": getattr(model, "failure_reason", None), + "last_run_at": model.last_run_at.isoformat() if getattr(model, "last_run_at", None) else None, + "run_duration": getattr(model, "run_duration", None), + } + no_code_model_structure.append(model_data) model_structure: dict[str, Any] = { "title": "models", "key": f"{self.project_name}/models", diff --git a/backend/backend/core/migrations/0003_add_model_run_status.py b/backend/backend/core/migrations/0003_add_model_run_status.py new file mode 100644 index 0000000..c2e3ddd --- /dev/null +++ b/backend/backend/core/migrations/0003_add_model_run_status.py @@ -0,0 +1,53 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("core", "0002_seed_data"), + ] + + operations = [ + migrations.AddField( + model_name="configmodels", + name="run_status", + field=models.CharField( + choices=[ + ("NOT_STARTED", "Not Started"), + ("RUNNING", "Running"), + ("SUCCESS", "Success"), + ("FAILED", "Failed"), + ], + default="NOT_STARTED", + help_text="Current execution status of the model", + max_length=20, + ), + ), + migrations.AddField( + model_name="configmodels", + name="failure_reason", + field=models.TextField( + blank=True, + help_text="Error message if the model execution failed", + null=True, + ), + ), + migrations.AddField( + model_name="configmodels", + name="last_run_at", + field=models.DateTimeField( + blank=True, + help_text="Timestamp of the last execution", + null=True, + ), + ), + migrations.AddField( + model_name="configmodels", + name="run_duration", + field=models.FloatField( + blank=True, + help_text="Duration of last execution in seconds", + null=True, + ), + ), + ] diff --git a/backend/backend/core/models/config_models.py b/backend/backend/core/models/config_models.py index e7b7aa2..f699cac 100644 --- a/backend/backend/core/models/config_models.py +++ b/backend/backend/core/models/config_models.py @@ -19,6 +19,12 @@ class ConfigModels(DefaultOrganizationMixin, BaseModel): This model is used to store the no code models. """ + class RunStatus(models.TextChoices): + NOT_STARTED = "NOT_STARTED", "Not Started" + RUNNING = "RUNNING", "Running" + SUCCESS = "SUCCESS", "Success" + FAILED = "FAILED", "Failed" + def get_model_upload_path(self, filename: str) -> str: """ This returns the file path based on the org and project dynamically. @@ -94,6 +100,29 @@ class Meta: last_modified_by = models.JSONField(default=dict) last_modified_at = models.DateTimeField(auto_now=True) + # Execution status tracking + run_status = models.CharField( + max_length=20, + choices=RunStatus.choices, + default=RunStatus.NOT_STARTED, + help_text="Current execution status of the model", + ) + failure_reason = models.TextField( + null=True, + blank=True, + help_text="Error message if the model execution failed", + ) + last_run_at = models.DateTimeField( + null=True, + blank=True, + help_text="Timestamp of the last execution", + ) + run_duration = models.FloatField( + null=True, + blank=True, + help_text="Duration of last execution in seconds", + ) + # Current Manager config_objects = models.Manager() diff --git a/backend/backend/core/routers/execute/views.py b/backend/backend/core/routers/execute/views.py index 742d277..648a952 100644 --- a/backend/backend/core/routers/execute/views.py +++ b/backend/backend/core/routers/execute/views.py @@ -57,12 +57,17 @@ def execute_run_command(request: Request, project_id: str) -> Response: ) logger.info(f"[execute_run_command] API called - project_id={project_id}, file_name={file_name}, environment_id={environment_id}") app = ApplicationContext(project_id=project_id) - app.execute_visitran_run_command(current_model=file_name, environment_id=environment_id) - app.visitran_context.close_db_connection() - app.backup_current_no_code_model() - logger.info(f"[execute_run_command] Completed successfully for file_name={file_name}") - _data = {"status": "success"} - return Response(data=_data) + try: + app.execute_visitran_run_command(current_model=file_name, environment_id=environment_id) + app.visitran_context.close_db_connection() + app.backup_current_no_code_model() + logger.info(f"[execute_run_command] Completed successfully for file_name={file_name}") + _data = {"status": "success"} + return Response(data=_data) + except Exception as e: + logger.error(f"[execute_run_command] DAG execution failed for file_name={file_name}: {e}") + _data = {"status": "failed", "error_message": str(e)} + return Response(data=_data, status=status.HTTP_400_BAD_REQUEST) diff --git a/backend/backend/utils/cache_service/decorators/cache_decorator.py b/backend/backend/utils/cache_service/decorators/cache_decorator.py index 32f8d4e..6b5ba18 100644 --- a/backend/backend/utils/cache_service/decorators/cache_decorator.py +++ b/backend/backend/utils/cache_service/decorators/cache_decorator.py @@ -96,6 +96,7 @@ def wrapped(view_or_request, *args, **kwargs): except Exception as e: Logger.exception("Error executing view function") + raise return response diff --git a/backend/visitran/visitran.py b/backend/visitran/visitran.py index c75b20e..1d8d1f4 100644 --- a/backend/visitran/visitran.py +++ b/backend/visitran/visitran.py @@ -15,6 +15,7 @@ import ibis import networkx as nx +from django.utils import timezone from visitran import utils from visitran.adapters.adapter import BaseAdapter from visitran.adapters.seed import BaseSeed @@ -71,6 +72,12 @@ from visitran.templates.model import VisitranModel from visitran.templates.snapshot import VisitranSnapshot +# Import Django models for status tracking +try: + from backend.core.models.config_models import ConfigModels +except ImportError: + ConfigModels = None + warnings.filterwarnings("ignore", message=".*?pkg_resources.*?") from matplotlib import pyplot as plt # noqa: E402 @@ -228,6 +235,38 @@ def sort_func(node_key: str): self.sorted_dag_nodes = list(nx.lexicographical_topological_sort(self.dag, key=sort_func)) fire_event(SortedDAGNodes(sorted_dag_nodes=str(self.sorted_dag_nodes))) + def _update_model_status(self, model_name: str, run_status: str, failure_reason: str = None) -> None: + """Update the run status of a model in the database.""" + if ConfigModels is None: + return + + try: + class_name = model_name.split("'")[1].split(".")[-2] if "'" in model_name else model_name + + session = getattr(self.context, "session", None) + if not session: + return + + project_id = session.project_id + if not project_id: + return + + model_instance = ConfigModels.objects.get( + project_instance__project_uuid=project_id, + model_name=class_name, + ) + model_instance.run_status = run_status + model_instance.last_run_at = timezone.now() + + if run_status == ConfigModels.RunStatus.FAILED: + model_instance.failure_reason = failure_reason + elif run_status == ConfigModels.RunStatus.SUCCESS: + model_instance.failure_reason = None + + model_instance.save(update_fields=["run_status", "last_run_at", "failure_reason"]) + except Exception as e: + logging.warning(f"Failed to update model status for {model_name}: {e}") + def execute_graph(self) -> None: """Executes the sorted DAG elements one by one.""" dag_nodes = self.sorted_dag_nodes @@ -237,6 +276,9 @@ def execute_graph(self) -> None: node = self.dag.nodes[node_name]["model_object"] is_executable = self.dag.nodes[node_name].get("executable", True) try: + # Set status to RUNNING before execution + self._update_model_status(str(node_name), ConfigModels.RunStatus.RUNNING if ConfigModels else "RUNNING") + # Apply model_configs override from deployment configuration self._apply_model_config_override(node) @@ -270,6 +312,9 @@ def execute_graph(self) -> None: self.db_adapter.db_connection.create_schema(node.destination_schema_name) # create if not exists self.db_adapter.run_model(visitran_model=node) + # Set status to SUCCESS after successful execution + self._update_model_status(str(node_name), ConfigModels.RunStatus.SUCCESS if ConfigModels else "SUCCESS") + base_result = BaseResult( node_name=str(node_name), sequence_num=sequence_number, @@ -282,11 +327,22 @@ def execute_graph(self) -> None: sequence_number += 1 BASE_RESULT.append(base_result) except VisitranBaseExceptions as visitran_err: + self._update_model_status( + str(node_name), + ConfigModels.RunStatus.FAILED if ConfigModels else "FAILED", + failure_reason=str(visitran_err), + ) raise visitran_err except Exception as err: dest_table = node.destination_table_name sch_name = node.destination_schema_name err_trace = repr(err) + + self._update_model_status( + str(node_name), + ConfigModels.RunStatus.FAILED if ConfigModels else "FAILED", + failure_reason=err_trace, + ) base_result = BaseResult( node_name=str(node_name), sequence_num=sequence_number, diff --git a/frontend/src/ide/editor/no-code-model/no-code-model.jsx b/frontend/src/ide/editor/no-code-model/no-code-model.jsx index 80304d0..1546469 100644 --- a/frontend/src/ide/editor/no-code-model/no-code-model.jsx +++ b/frontend/src/ide/editor/no-code-model/no-code-model.jsx @@ -1602,6 +1602,7 @@ function NoCodeModel({ nodeData }) { axios(requestOptions) .then(() => { getSampleData(undefined, undefined, spec); + setRefreshModels(true); }) .catch((error) => { const notifKey = notify({ @@ -1636,6 +1637,7 @@ function NoCodeModel({ nodeData }) { }); setTransformationErrorFlag(true); setIsLoading(false); + setRefreshModels(true); }); }; diff --git a/frontend/src/ide/explorer/explorer-component.jsx b/frontend/src/ide/explorer/explorer-component.jsx index 83ef24a..8bdc5c4 100644 --- a/frontend/src/ide/explorer/explorer-component.jsx +++ b/frontend/src/ide/explorer/explorer-component.jsx @@ -16,6 +16,7 @@ import { Badge, theme, Checkbox, + Popover, } from "antd"; import { CaretDownOutlined, @@ -460,29 +461,47 @@ const IdeExplorer = ({ ); - // Add checkboxes to model children when in delete mode - item.children = item.children.map((child) => ({ - ...child, - title: ( - - {modelDeleteModeRef.current && ( - - handleItemSelectToggle( - child.key, - selectedModelKeysRef, - setSelectedModelKeys - ) - } - onClick={(e) => e.stopPropagation()} - style={{ marginRight: 6 }} - /> - )} - {child.title} - - ), - })); + // Add checkboxes and run status to model children + item.children = item.children.map((child) => { + const statusBadge = getModelRunStatus( + child.run_status, + child.failure_reason, + child.last_run_at + ); + return { + ...child, + title: ( + + {modelDeleteModeRef.current && ( + + handleItemSelectToggle( + child.key, + selectedModelKeysRef, + setSelectedModelKeys + ) + } + onClick={(e) => e.stopPropagation()} + style={{ marginRight: 6 }} + /> + )} + {statusBadge && ( + + {statusBadge} + + )} + {child.title} + + ), + }; + }); } return item; }); @@ -635,6 +654,73 @@ const IdeExplorer = ({ } }; + const getModelRunStatus = (runStatus, failureReason, lastRunAt) => { + const dotStyle = { + display: "inline-block", + width: "7px", + height: "7px", + borderRadius: "50%", + verticalAlign: "middle", + }; + + if (runStatus === "RUNNING") { + return ( + + + + ); + } else if (runStatus === "FAILED") { + const popoverContent = ( +
+ {failureReason && ( +
+              {failureReason}
+            
+ )} + {lastRunAt && ( +
+ Last run: {new Date(lastRunAt).toLocaleString()} +
+ )} +
+ ); + return ( + + + + ); + } else if (runStatus === "SUCCESS") { + const tooltipTitle = ( +
+
Success
+ {lastRunAt && ( +
+ Last run: {new Date(lastRunAt).toLocaleString()} +
+ )} +
+ ); + return ( + + + + ); + } + return null; + }; + const handleIconFunctions = (type, param) => { if (type === "run_seed") { if (