Skip to content

Commit 73ead92

Browse files
abhizipstackclaude
andauthored
feat: track model execution status with failure icon in explorer (#54)
* feat: track model execution status and show failure icon in explorer Implement model-level execution status tracking (NOT_STARTED, RUNNING, SUCCESS, FAILED) at DAG node level with visual indicators in the file explorer. Status is tracked per individual DAG node, ensuring accurate status for each model even when downstream dependencies fail. Backend changes: - Add RunStatus enum and 4 new fields to ConfigModels (run_status, failure_reason, last_run_at, run_duration) - Migration 0003_add_model_run_status - Update file_explorer.load_models to return status fields - Add _update_model_status to DAG executor — called before execution (RUNNING), after success (SUCCESS), and on exception (FAILED with reason) - Update execute/views.py to return 400 on DAG execution failures - Fix clear_cache decorator to re-raise exceptions instead of swallowing them silently Frontend changes: - Add getModelRunStatus helper to render colored dot badges next to model names in the explorer tree - Running: blue, Success: green, Failed: red - Show Popover on hover over failed status with full error message and last run timestamp - Trigger explorer refresh via setRefreshModels after runTransformation succeeds or fails Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: address PR review feedback on model run status tracking - Drop optional ConfigModels import; it's always present in the OSS build - Track run_duration via time.monotonic() and persist on SUCCESS/FAILED - Skip RUNNING update for non-executable parent nodes so they don't get stuck with a permanent blue badge in selective execution - Raise explicit errors when session/project_id missing instead of silent no-op - Stop returning raw exception strings from execute_run_command (CodeQL) - Refresh explorer tree on context-menu run failure so FAILED badge appears - Move getModelRunStatus to module scope and use antd theme tokens instead of hardcoded hex colors Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: satisfy prettier multiline formatting for status dot style spans Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: increase project_schema max_length from 20 to 1024 Requested by @tahierhussain in PR #55 review — the project_schema field was too short for schemas with long names or multiple schemas. Bundled into migration 0003 alongside the model run-status fields since both target the core app and depend on 0002_seed_data. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: address Greptile P1s — CLI-safe timezone import + DB connection leak P1: `from django.utils import timezone` at module level crashes when visitran is used as a standalone CLI without Django installed. Wrapped in try/except with a minimal fallback that provides timezone.now() via stdlib datetime. P1: close_db_connection() was only called in the success path of execute_run_command. On exception the connection leaked. Moved to a finally block so it always runs regardless of outcome. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 6420853 commit 73ead92

9 files changed

Lines changed: 340 additions & 39 deletions

File tree

backend/backend/application/file_explorer/file_explorer.py

Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -93,18 +93,25 @@ def load_models(self, session: Session):
9393
# Sort models by execution order (DAG order)
9494
sorted_model_names = topological_sort_models(models_with_refs)
9595

96+
# Build a lookup from model name -> model object for status fields
97+
model_lookup = {m.model_name: m for m in all_models}
98+
9699
# Build the model structure in sorted order
97100
no_code_model_structure = []
98101
for no_code_model_name in sorted_model_names:
99-
no_code_model_structure.append(
100-
{
101-
"extension": no_code_model_name,
102-
"title": no_code_model_name,
103-
"key": f"{self.project_name}/models/no_code/{no_code_model_name}",
104-
"is_folder": False,
105-
"type": "NO_CODE_MODEL",
106-
}
107-
)
102+
model = model_lookup.get(no_code_model_name)
103+
model_data = {
104+
"extension": no_code_model_name,
105+
"title": no_code_model_name,
106+
"key": f"{self.project_name}/models/no_code/{no_code_model_name}",
107+
"is_folder": False,
108+
"type": "NO_CODE_MODEL",
109+
"run_status": getattr(model, "run_status", None),
110+
"failure_reason": getattr(model, "failure_reason", None),
111+
"last_run_at": model.last_run_at.isoformat() if getattr(model, "last_run_at", None) else None,
112+
"run_duration": getattr(model, "run_duration", None),
113+
}
114+
no_code_model_structure.append(model_data)
108115
model_structure: dict[str, Any] = {
109116
"title": "models",
110117
"key": f"{self.project_name}/models",
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
from django.db import migrations, models
2+
3+
4+
class Migration(migrations.Migration):
5+
6+
dependencies = [
7+
("core", "0002_seed_data"),
8+
]
9+
10+
operations = [
11+
migrations.AddField(
12+
model_name="configmodels",
13+
name="run_status",
14+
field=models.CharField(
15+
choices=[
16+
("NOT_STARTED", "Not Started"),
17+
("RUNNING", "Running"),
18+
("SUCCESS", "Success"),
19+
("FAILED", "Failed"),
20+
],
21+
default="NOT_STARTED",
22+
help_text="Current execution status of the model",
23+
max_length=20,
24+
),
25+
),
26+
migrations.AddField(
27+
model_name="configmodels",
28+
name="failure_reason",
29+
field=models.TextField(
30+
blank=True,
31+
help_text="Error message if the model execution failed",
32+
null=True,
33+
),
34+
),
35+
migrations.AddField(
36+
model_name="configmodels",
37+
name="last_run_at",
38+
field=models.DateTimeField(
39+
blank=True,
40+
help_text="Timestamp of the last execution",
41+
null=True,
42+
),
43+
),
44+
migrations.AddField(
45+
model_name="configmodels",
46+
name="run_duration",
47+
field=models.FloatField(
48+
blank=True,
49+
help_text="Duration of last execution in seconds",
50+
null=True,
51+
),
52+
),
53+
migrations.AlterField(
54+
model_name="projectdetails",
55+
name="project_schema",
56+
field=models.CharField(
57+
max_length=1024,
58+
blank=True,
59+
null=True,
60+
),
61+
),
62+
]

backend/backend/core/models/config_models.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,12 @@ class ConfigModels(DefaultOrganizationMixin, BaseModel):
1919
This model is used to store the no code models.
2020
"""
2121

22+
class RunStatus(models.TextChoices):
23+
NOT_STARTED = "NOT_STARTED", "Not Started"
24+
RUNNING = "RUNNING", "Running"
25+
SUCCESS = "SUCCESS", "Success"
26+
FAILED = "FAILED", "Failed"
27+
2228
def get_model_upload_path(self, filename: str) -> str:
2329
"""
2430
This returns the file path based on the org and project dynamically.
@@ -94,6 +100,29 @@ class Meta:
94100
last_modified_by = models.JSONField(default=dict)
95101
last_modified_at = models.DateTimeField(auto_now=True)
96102

103+
# Execution status tracking
104+
run_status = models.CharField(
105+
max_length=20,
106+
choices=RunStatus.choices,
107+
default=RunStatus.NOT_STARTED,
108+
help_text="Current execution status of the model",
109+
)
110+
failure_reason = models.TextField(
111+
null=True,
112+
blank=True,
113+
help_text="Error message if the model execution failed",
114+
)
115+
last_run_at = models.DateTimeField(
116+
null=True,
117+
blank=True,
118+
help_text="Timestamp of the last execution",
119+
)
120+
run_duration = models.FloatField(
121+
null=True,
122+
blank=True,
123+
help_text="Duration of last execution in seconds",
124+
)
125+
97126
# Current Manager
98127
config_objects = models.Manager()
99128

backend/backend/core/models/project_details.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,7 @@ def delete(self, *args, **kwargs):
102102
project_path = models.CharField(max_length=100)
103103
profile_path = models.CharField(max_length=100)
104104

105-
project_schema = models.CharField(max_length=20, blank=True, null=True)
105+
project_schema = models.CharField(max_length=1024, blank=True, null=True)
106106
# User specific access control fields
107107
created_by = models.JSONField(default=dict)
108108
created_at = models.DateTimeField(auto_now_add=True)

backend/backend/core/routers/execute/views.py

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -57,12 +57,18 @@ def execute_run_command(request: Request, project_id: str) -> Response:
5757
)
5858
logger.info(f"[execute_run_command] API called - project_id={project_id}, file_name={file_name}, environment_id={environment_id}")
5959
app = ApplicationContext(project_id=project_id)
60-
app.execute_visitran_run_command(current_model=file_name, environment_id=environment_id)
61-
app.visitran_context.close_db_connection()
62-
app.backup_current_no_code_model()
63-
logger.info(f"[execute_run_command] Completed successfully for file_name={file_name}")
64-
_data = {"status": "success"}
65-
return Response(data=_data)
60+
try:
61+
app.execute_visitran_run_command(current_model=file_name, environment_id=environment_id)
62+
app.backup_current_no_code_model()
63+
logger.info(f"[execute_run_command] Completed successfully for file_name={file_name}")
64+
_data = {"status": "success"}
65+
return Response(data=_data)
66+
except Exception:
67+
logger.exception(f"[execute_run_command] DAG execution failed for file_name={file_name}")
68+
_data = {"status": "failed", "error_message": "Model execution failed. Check server logs for details."}
69+
return Response(data=_data, status=status.HTTP_400_BAD_REQUEST)
70+
finally:
71+
app.visitran_context.close_db_connection()
6672

6773

6874

backend/backend/utils/cache_service/decorators/cache_decorator.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,7 @@ def wrapped(view_or_request, *args, **kwargs):
9696

9797
except Exception as e:
9898
Logger.exception("Error executing view function")
99+
raise
99100

100101
return response
101102

backend/visitran/visitran.py

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import concurrent.futures
44
import datetime
5+
import time
56
import importlib
67
import logging
78
import re
@@ -15,6 +16,15 @@
1516

1617
import ibis
1718
import networkx as nx
19+
try:
20+
from django.utils import timezone
21+
except ImportError:
22+
from datetime import datetime, timezone as _tz
23+
24+
class timezone:
25+
@staticmethod
26+
def now():
27+
return datetime.now(_tz.utc)
1828
from visitran import utils
1929
from visitran.adapters.adapter import BaseAdapter
2030
from visitran.adapters.seed import BaseSeed
@@ -71,6 +81,8 @@
7181
from visitran.templates.model import VisitranModel
7282
from visitran.templates.snapshot import VisitranSnapshot
7383

84+
from backend.core.models.config_models import ConfigModels
85+
7486
warnings.filterwarnings("ignore", message=".*?pkg_resources.*?")
7587
from matplotlib import pyplot as plt # noqa: E402
7688

@@ -228,6 +240,53 @@ def sort_func(node_key: str):
228240
self.sorted_dag_nodes = list(nx.lexicographical_topological_sort(self.dag, key=sort_func))
229241
fire_event(SortedDAGNodes(sorted_dag_nodes=str(self.sorted_dag_nodes)))
230242

243+
def _update_model_status(
244+
self,
245+
model_name: str,
246+
run_status: str,
247+
failure_reason: str = None,
248+
run_duration: float = None,
249+
) -> None:
250+
"""Update the run status of a model in the database."""
251+
try:
252+
# node_name str looks like: "<class 'project.models.stg_order_summaries.StgOrderSummaries'>"
253+
# ConfigModels.model_name stores the module/file name (e.g. 'stg_order_summaries'),
254+
# which is the second-to-last dotted segment — not the CamelCase class name.
255+
class_name = model_name.split("'")[1].split(".")[-2] if "'" in model_name else model_name
256+
257+
session = getattr(self.context, "session", None)
258+
if not session:
259+
raise ValueError(
260+
f"Cannot update status for model '{class_name}': no session on execution context"
261+
)
262+
263+
project_id = session.project_id
264+
if not project_id:
265+
raise ValueError(
266+
f"Cannot update status for model '{class_name}': session has no project_id"
267+
)
268+
269+
model_instance = ConfigModels.objects.get(
270+
project_instance__project_uuid=project_id,
271+
model_name=class_name,
272+
)
273+
model_instance.run_status = run_status
274+
model_instance.last_run_at = timezone.now()
275+
276+
if run_status == ConfigModels.RunStatus.FAILED:
277+
model_instance.failure_reason = failure_reason
278+
elif run_status == ConfigModels.RunStatus.SUCCESS:
279+
model_instance.failure_reason = None
280+
281+
update_fields = ["run_status", "last_run_at", "failure_reason"]
282+
if run_duration is not None:
283+
model_instance.run_duration = run_duration
284+
update_fields.append("run_duration")
285+
286+
model_instance.save(update_fields=update_fields)
287+
except Exception:
288+
logging.exception(f"Failed to update model status for {model_name}")
289+
231290
def execute_graph(self) -> None:
232291
"""Executes the sorted DAG elements one by one."""
233292
dag_nodes = self.sorted_dag_nodes
@@ -236,7 +295,11 @@ def execute_graph(self) -> None:
236295
node_name: VisitranModel = dag_nodes.pop(0)
237296
node = self.dag.nodes[node_name]["model_object"]
238297
is_executable = self.dag.nodes[node_name].get("executable", True)
298+
start_time = time.monotonic()
239299
try:
300+
if is_executable:
301+
self._update_model_status(str(node_name), ConfigModels.RunStatus.RUNNING)
302+
240303
# Apply model_configs override from deployment configuration
241304
self._apply_model_config_override(node)
242305

@@ -270,6 +333,12 @@ def execute_graph(self) -> None:
270333
self.db_adapter.db_connection.create_schema(node.destination_schema_name) # create if not exists
271334
self.db_adapter.run_model(visitran_model=node)
272335

336+
self._update_model_status(
337+
str(node_name),
338+
ConfigModels.RunStatus.SUCCESS,
339+
run_duration=time.monotonic() - start_time,
340+
)
341+
273342
base_result = BaseResult(
274343
node_name=str(node_name),
275344
sequence_num=sequence_number,
@@ -282,11 +351,24 @@ def execute_graph(self) -> None:
282351
sequence_number += 1
283352
BASE_RESULT.append(base_result)
284353
except VisitranBaseExceptions as visitran_err:
354+
self._update_model_status(
355+
str(node_name),
356+
ConfigModels.RunStatus.FAILED,
357+
failure_reason=str(visitran_err),
358+
run_duration=time.monotonic() - start_time,
359+
)
285360
raise visitran_err
286361
except Exception as err:
287362
dest_table = node.destination_table_name
288363
sch_name = node.destination_schema_name
289364
err_trace = repr(err)
365+
366+
self._update_model_status(
367+
str(node_name),
368+
ConfigModels.RunStatus.FAILED,
369+
failure_reason=err_trace,
370+
run_duration=time.monotonic() - start_time,
371+
)
290372
base_result = BaseResult(
291373
node_name=str(node_name),
292374
sequence_num=sequence_number,

frontend/src/ide/editor/no-code-model/no-code-model.jsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1875,6 +1875,7 @@ function NoCodeModel({ nodeData }) {
18751875
axios(requestOptions)
18761876
.then(() => {
18771877
getSampleData(undefined, undefined, spec);
1878+
setRefreshModels(true);
18781879
})
18791880
.catch((error) => {
18801881
const notifKey = notify({
@@ -1909,6 +1910,7 @@ function NoCodeModel({ nodeData }) {
19091910
});
19101911
setTransformationErrorFlag(true);
19111912
setIsLoading(false);
1913+
setRefreshModels(true);
19121914
});
19131915
};
19141916

0 commit comments

Comments
 (0)