From fd3161cdbc5a843f282667bb93af5dd4c848fe23 Mon Sep 17 00:00:00 2001 From: abhizipstack Date: Thu, 16 Apr 2026 19:31:38 +0530 Subject: [PATCH 01/20] =?UTF-8?q?feat:=20user-facing=20activity=20logs=20?= =?UTF-8?q?=E2=80=94=203=20core=20model=20events?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces a UserLevel event tier and 3 curated, plain-language events that replace developer-internal log dumps for model execution: Building model "mmodela" → testing.mmodela as TABLE from "testing.country" Model "mmodela" built successfully in 0.42s Model "mmodela" failed: Table Not Found … Backend - base_types.py: UserLevel class with audience()="user"; BaseEvent gets a default audience()="developer" so all existing events are backward-compatible. - proto_types.py: ModelRunStarted, ModelRunSucceeded, ModelRunFailed message types. - types.py: 3 event classes (U001-U003) inheriting UserLevel. - log_helper.py: LogHelper.log() accepts audience param, included in the socket payload dict. - eventmgr.py: write_line reads audience from the event and passes through to LogHelper. - visitran.py: fires the 3 events from execute_graph — started before run_model, succeeded after, failed in both exception handlers. Frontend - Socket handler reads data?.data?.audience alongside level/message. - logsInfo entries now carry { level, message, audience }. - New "User activity" option at the top of the log-level dropdown (default). Filters to audience==="user" only. - Existing options (All logs, Info+, Warn+, Error) continue to show developer logs regardless of audience. Co-Authored-By: Claude Opus 4.6 (1M context) --- backend/visitran/events/base_types.py | 14 ++++++++ backend/visitran/events/eventmgr.py | 3 +- backend/visitran/events/log_helper.py | 2 ++ backend/visitran/events/proto_types.py | 33 +++++++++++++++++ backend/visitran/events/types.py | 36 ++++++++++++++++++- backend/visitran/visitran.py | 35 ++++++++++++++++-- .../editor/no-code-model/no-code-model.jsx | 16 +++++---- 7 files changed, 129 insertions(+), 10 deletions(-) diff --git a/backend/visitran/events/base_types.py b/backend/visitran/events/base_types.py index 8dee9eb..7109820 100644 --- a/backend/visitran/events/base_types.py +++ b/backend/visitran/events/base_types.py @@ -28,6 +28,9 @@ class BaseEvent: def level_tag(self) -> EventLevel: return EventLevel.DEBUG + def audience(self) -> str: + return "developer" + def message(self) -> str: raise NotImplementedError("message() not implemented for event") @@ -87,6 +90,17 @@ def level_tag(self) -> EventLevel: return EventLevel.ERROR +@dataclass +class UserLevel(BaseEvent): + """User-facing events shown in the activity log (not developer noise).""" + + def level_tag(self) -> EventLevel: + return EventLevel.INFO + + def audience(self) -> str: + return "user" + + class NoFile: """Prevents an event from going to the file.""" diff --git a/backend/visitran/events/eventmgr.py b/backend/visitran/events/eventmgr.py index 52b00d5..b6b079f 100644 --- a/backend/visitran/events/eventmgr.py +++ b/backend/visitran/events/eventmgr.py @@ -129,7 +129,8 @@ def write_line(self, msg: EventMsg) -> None: # send_to_logger(self._python_logger, msg.info.level, line) #send logs to file # Using shared memory to write the logs # LogHelper.publish(LogHelper.log(line, msg.info.level)) - LogHelper.publish_log(StateStore.get("log_events_id"), LogHelper.log(line, msg.info.level)) + audience = getattr(msg.data, "audience", lambda: "developer")() + LogHelper.publish_log(StateStore.get("log_events_id"), LogHelper.log(line, msg.info.level, audience)) if self._stream is not None and self.name == "stdout_log": self._stream.write(line + "\n") diff --git a/backend/visitran/events/log_helper.py b/backend/visitran/events/log_helper.py index eff16eb..0af2083 100644 --- a/backend/visitran/events/log_helper.py +++ b/backend/visitran/events/log_helper.py @@ -41,10 +41,12 @@ class LogHelper: def log( message: str, level: str = "INFO", + audience: str = "developer", ) -> dict[str, str]: return { "level": level, "message": message, + "audience": audience, } @staticmethod diff --git a/backend/visitran/events/proto_types.py b/backend/visitran/events/proto_types.py index 81c313d..6cd1981 100644 --- a/backend/visitran/events/proto_types.py +++ b/backend/visitran/events/proto_types.py @@ -1033,3 +1033,36 @@ class FailedScheduleJob(betterproto.Message): class FailedScheduleJobMsg(betterproto.Message): info: "EventInfo" = betterproto.message_field(1) data: "FailedScheduleJob" = betterproto.message_field(2) + + +@dataclass +class ModelRunStarted(betterproto.Message): + model_name: str = betterproto.string_field(1) + source_table: str = betterproto.string_field(2) + destination_table: str = betterproto.string_field(3) + materialization: str = betterproto.string_field(4) + +@dataclass +class ModelRunStartedMsg(betterproto.Message): + info: "EventInfo" = betterproto.message_field(1) + data: "ModelRunStarted" = betterproto.message_field(2) + +@dataclass +class ModelRunSucceeded(betterproto.Message): + model_name: str = betterproto.string_field(1) + duration_seconds: float = betterproto.float_field(2) + +@dataclass +class ModelRunSucceededMsg(betterproto.Message): + info: "EventInfo" = betterproto.message_field(1) + data: "ModelRunSucceeded" = betterproto.message_field(2) + +@dataclass +class ModelRunFailed(betterproto.Message): + model_name: str = betterproto.string_field(1) + error: str = betterproto.string_field(2) + +@dataclass +class ModelRunFailedMsg(betterproto.Message): + info: "EventInfo" = betterproto.message_field(1) + data: "ModelRunFailed" = betterproto.message_field(2) diff --git a/backend/visitran/events/types.py b/backend/visitran/events/types.py index 104dd9a..76988a1 100644 --- a/backend/visitran/events/types.py +++ b/backend/visitran/events/types.py @@ -3,7 +3,7 @@ from dataclasses import dataclass import visitran.events.proto_types as proto_type -from visitran.events.base_types import DebugLevel, ErrorLevel, InfoLevel, WarnLevel +from visitran.events.base_types import DebugLevel, ErrorLevel, InfoLevel, UserLevel, WarnLevel # # | Code | Description | @@ -820,3 +820,37 @@ def code(self) -> str: def message(self) -> str: return f"{self.task_type} Task update failed, task_ID: {self.task_id}. task details: {self.cron_data}" + + +# ─── User-facing activity events ───────────────────────────────────── +# These inherit from UserLevel so they carry audience="user" and are +# shown in the frontend's "User activity" log view by default. + +@dataclass +class ModelRunStartedEvent(UserLevel, proto_type.ModelRunStarted): + def code(self) -> str: + return "U001" + + def message(self) -> str: + mat = f" as {self.materialization}" if self.materialization else "" + return f'Building model "{self.model_name}" → {self.destination_table}{mat} from "{self.source_table}"' + + +@dataclass +class ModelRunSucceededEvent(UserLevel, proto_type.ModelRunSucceeded): + def code(self) -> str: + return "U002" + + def message(self) -> str: + dur = f" in {self.duration_seconds:.1f}s" if self.duration_seconds else "" + return f'Model "{self.model_name}" built successfully{dur}' + + +@dataclass +class ModelRunFailedEvent(UserLevel, proto_type.ModelRunFailed): + def code(self) -> str: + return "U003" + + def message(self) -> str: + short_err = (self.error[:120] + "…") if len(self.error) > 120 else self.error + return f'Model "{self.model_name}" failed: {short_err}' diff --git a/backend/visitran/visitran.py b/backend/visitran/visitran.py index a5e49bb..9d37207 100644 --- a/backend/visitran/visitran.py +++ b/backend/visitran/visitran.py @@ -75,6 +75,9 @@ def now(): SortedDAGNodes, TestExecutionCompleted, TestExecutionFailed, + ModelRunStartedEvent, + ModelRunSucceededEvent, + ModelRunFailedEvent, ) from visitran.materialization import Materialization from visitran.singleton import Singleton @@ -313,6 +316,16 @@ def execute_graph(self) -> None: if not is_executable: continue + _model_display = getattr(node, "destination_table_name", "") or str(node_name) + _mat = node.materialization.value if hasattr(node.materialization, "value") else str(node.materialization) + fire_event( + ModelRunStartedEvent( + model_name=_model_display, + source_table=f"{node.source_schema_name}.{node.source_table_name}", + destination_table=f"{node.destination_schema_name}.{node.destination_table_name}", + materialization=_mat, + ) + ) fire_event( ExecutingModelNode( database=node.database, @@ -333,10 +346,17 @@ 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) + _elapsed = time.monotonic() - start_time + fire_event( + ModelRunSucceededEvent( + model_name=_model_display, + duration_seconds=round(_elapsed, 2), + ) + ) self._update_model_status( str(node_name), ConfigModels.RunStatus.SUCCESS, - run_duration=time.monotonic() - start_time, + run_duration=_elapsed, ) base_result = BaseResult( @@ -351,6 +371,12 @@ def execute_graph(self) -> None: sequence_number += 1 BASE_RESULT.append(base_result) except VisitranBaseExceptions as visitran_err: + fire_event( + ModelRunFailedEvent( + model_name=getattr(node, "destination_table_name", "") or str(node_name), + error=str(visitran_err), + ) + ) self._update_model_status( str(node_name), ConfigModels.RunStatus.FAILED, @@ -362,7 +388,12 @@ def execute_graph(self) -> None: dest_table = node.destination_table_name sch_name = node.destination_schema_name err_trace = repr(err) - + fire_event( + ModelRunFailedEvent( + model_name=dest_table or str(node_name), + error=err_trace, + ) + ) self._update_model_status( str(node_name), ConfigModels.RunStatus.FAILED, 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 fe7aa9e..17423a8 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 @@ -230,7 +230,7 @@ function NoCodeModel({ nodeData }) { const [reactFlowInstance, setReactFlowInstance] = useState(null); const [logsInfo, setLogsInfo] = useState([]); const [logsLevel, setLogsLevel] = useState( - () => localStorage.getItem("visitran.logsLevel") || "info" + () => localStorage.getItem("visitran.logsLevel") || "user" ); const [reveal, setReveal] = useState(false); const [seqOrder, setSeqOrder] = useState({}); @@ -415,10 +415,12 @@ function NoCodeModel({ nodeData }) { const filteredLogs = useMemo( () => - logsInfo.filter( - (entry) => + logsInfo.filter((entry) => { + if (logsLevel === "user") return entry.audience === "user"; + return ( (LOG_LEVEL_RANK[entry.level] ?? 1) >= (LOG_LEVEL_RANK[logsLevel] ?? 1) - ), + ); + }), [logsInfo, logsLevel] ); const handleLogsLevelChange = (value) => { @@ -716,8 +718,9 @@ function NoCodeModel({ nodeData }) { size="small" value={logsLevel} onChange={handleLogsLevelChange} - style={{ width: 140 }} + style={{ width: 160 }} options={[ + { value: "user", label: "User activity" }, { value: "debug", label: "All logs" }, { value: "info", label: "Info & above" }, { value: "warn", label: "Warnings & errors" }, @@ -2046,6 +2049,7 @@ function NoCodeModel({ nodeData }) { newSocket.on(`logs:${socketSessionId}`, (data) => { const message = data?.data?.message; const level = (data?.data?.level || "info").toLowerCase(); + const audience = data?.data?.audience || "developer"; if (!message) return; const doc = document.getElementsByClassName("logsSection"); if (doc[0]) { @@ -2053,7 +2057,7 @@ function NoCodeModel({ nodeData }) { doc[0].scrollTop = doc[0].scrollHeight; }, 800); } - setLogsInfo((old) => [...old, { level, message }]); + setLogsInfo((old) => [...old, { level, message, audience }]); }); }); }); From 3ade023d3a80703c127e3113a3d741c4a7fa5e2f Mon Sep 17 00:00:00 2001 From: abhizipstack Date: Thu, 16 Apr 2026 19:35:53 +0530 Subject: [PATCH 02/20] =?UTF-8?q?fix:=20CreateConnection=20crash=20?= =?UTF-8?q?=E2=80=94=20hasDetailsChanged=20used=20before=20declaration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit useMemo for hasDetailsChanged was declared at line 411 but referenced by the handleCreateOrUpdate useCallback at line 148. JavaScript's temporal dead zone (TDZ) caused a ReferenceError on render. Moved the useMemo above the useCallback that depends on it. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../components/environment/CreateConnection.jsx | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/frontend/src/base/components/environment/CreateConnection.jsx b/frontend/src/base/components/environment/CreateConnection.jsx index 81ff920..7fecee9 100644 --- a/frontend/src/base/components/environment/CreateConnection.jsx +++ b/frontend/src/base/components/environment/CreateConnection.jsx @@ -125,6 +125,14 @@ const CreateConnection = ({ getConnectionFields(); }, [getConnectionFields]); + const hasDetailsChanged = useMemo(() => { + if (!connectionId || !originalDbSelectionInfo) return false; + return ( + formName !== originalDbSelectionInfo.name || + formDescription !== originalDbSelectionInfo.description + ); + }, [connectionId, formName, formDescription, originalDbSelectionInfo]); + const handleCreateOrUpdate = useCallback(async () => { setIsCreateOrUpdateLoading(true); try { @@ -407,15 +415,6 @@ const CreateConnection = ({ [dbSelectionInfo.datasource_name, selectedOrgId, csrfToken, connType] ); - // Detect if connection name or description changed (metadata-only changes) - const hasDetailsChanged = useMemo(() => { - if (!connectionId || !originalDbSelectionInfo) return false; - return ( - formName !== originalDbSelectionInfo.name || - formDescription !== originalDbSelectionInfo.description - ); - }, [connectionId, formName, formDescription, originalDbSelectionInfo]); - const mappedDataSources = useMemo( () => dataSources.map((dSource) => ({ From 18663e5b281acb4fd6d4f76e843575da25b467aa Mon Sep 17 00:00:00 2001 From: abhizipstack Date: Thu, 16 Apr 2026 19:48:09 +0530 Subject: [PATCH 03/20] feat: style user-activity logs with visual distinction User-audience log entries now render with: - A primary-colored left border (3px, token.colorPrimary) - Subtle background fill (token.colorFillQuaternary) - Slightly bolder font (500 weight, 13px) - Error-colored text for failed events - No HTML parsing (user messages are plain text, no ANSI) Developer logs continue rendering with the existing ANSI-parsed style and severity-based coloring. The visual contrast makes it immediately clear which entries are user-facing activity messages vs developer-internal noise. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../editor/no-code-model/no-code-model.jsx | 35 +++++++++++++++---- 1 file changed, 28 insertions(+), 7 deletions(-) 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 17423a8..68b9681 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 @@ -734,13 +734,34 @@ function NoCodeModel({ nodeData }) { height: `calc(${bottomSectionRef.current.height} - 100px)`, }} > - {filteredLogs.map((el, index) => ( -
- ))} + {filteredLogs.map((el, index) => + el.audience === "user" ? ( +
+ {el.message} +
+ ) : ( +
+ ) + )}
), From 4bc2ce7a48b36a16d4e8f528e056b14486a2947c Mon Sep 17 00:00:00 2001 From: abhizipstack Date: Thu, 16 Apr 2026 19:59:05 +0530 Subject: [PATCH 04/20] fix: rename event classes to match proto Msg naming convention MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit msg_from_base_event builds the Msg class name as {ClassName}Msg. Our events were named ModelRunStartedEvent → looked for ModelRunStartedEventMsg which doesn't exist. Dropped the "Event" suffix so ModelRunStarted → ModelRunStartedMsg matches proto_types. Co-Authored-By: Claude Opus 4.6 (1M context) --- backend/visitran/events/types.py | 6 +++--- backend/visitran/visitran.py | 14 +++++++------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/backend/visitran/events/types.py b/backend/visitran/events/types.py index 76988a1..d295c01 100644 --- a/backend/visitran/events/types.py +++ b/backend/visitran/events/types.py @@ -827,7 +827,7 @@ def message(self) -> str: # shown in the frontend's "User activity" log view by default. @dataclass -class ModelRunStartedEvent(UserLevel, proto_type.ModelRunStarted): +class ModelRunStarted(UserLevel, proto_type.ModelRunStarted): def code(self) -> str: return "U001" @@ -837,7 +837,7 @@ def message(self) -> str: @dataclass -class ModelRunSucceededEvent(UserLevel, proto_type.ModelRunSucceeded): +class ModelRunSucceeded(UserLevel, proto_type.ModelRunSucceeded): def code(self) -> str: return "U002" @@ -847,7 +847,7 @@ def message(self) -> str: @dataclass -class ModelRunFailedEvent(UserLevel, proto_type.ModelRunFailed): +class ModelRunFailed(UserLevel, proto_type.ModelRunFailed): def code(self) -> str: return "U003" diff --git a/backend/visitran/visitran.py b/backend/visitran/visitran.py index 9d37207..722fc68 100644 --- a/backend/visitran/visitran.py +++ b/backend/visitran/visitran.py @@ -75,9 +75,9 @@ def now(): SortedDAGNodes, TestExecutionCompleted, TestExecutionFailed, - ModelRunStartedEvent, - ModelRunSucceededEvent, - ModelRunFailedEvent, + ModelRunStarted, + ModelRunSucceeded, + ModelRunFailed, ) from visitran.materialization import Materialization from visitran.singleton import Singleton @@ -319,7 +319,7 @@ def execute_graph(self) -> None: _model_display = getattr(node, "destination_table_name", "") or str(node_name) _mat = node.materialization.value if hasattr(node.materialization, "value") else str(node.materialization) fire_event( - ModelRunStartedEvent( + ModelRunStarted( model_name=_model_display, source_table=f"{node.source_schema_name}.{node.source_table_name}", destination_table=f"{node.destination_schema_name}.{node.destination_table_name}", @@ -348,7 +348,7 @@ def execute_graph(self) -> None: _elapsed = time.monotonic() - start_time fire_event( - ModelRunSucceededEvent( + ModelRunSucceeded( model_name=_model_display, duration_seconds=round(_elapsed, 2), ) @@ -372,7 +372,7 @@ def execute_graph(self) -> None: BASE_RESULT.append(base_result) except VisitranBaseExceptions as visitran_err: fire_event( - ModelRunFailedEvent( + ModelRunFailed( model_name=getattr(node, "destination_table_name", "") or str(node_name), error=str(visitran_err), ) @@ -389,7 +389,7 @@ def execute_graph(self) -> None: sch_name = node.destination_schema_name err_trace = repr(err) fire_event( - ModelRunFailedEvent( + ModelRunFailed( model_name=dest_table or str(node_name), error=err_trace, ) From a978408fe5992740d24604c2544b70f1bb5de447 Mon Sep 17 00:00:00 2001 From: abhizipstack Date: Thu, 16 Apr 2026 20:34:24 +0530 Subject: [PATCH 05/20] =?UTF-8?q?feat:=20add=20P1=20user-facing=20events?= =?UTF-8?q?=20=E2=80=94=20transformations,=20config,=20seeds?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 4 new UserLevel events covering the core model-tab operations: - TransformationApplied (U004): fired after set_model_transformation succeeds. Message: 'Applied sort transformation on "mdoela"' - TransformationDeleted (U005): fired after delete_model_transformation. Message: 'Removed filter transformation from "mdoela"' - ModelConfigured (U006): fired after set_model_config_and_reference. Message: 'Configured "mdoela" — source: raw.customers, destination: analytics.dim_customers' - SeedCompleted (U007): fired after successful/failed seed execution. Message: 'Seed "raw_customers" loaded into "raw"' or 'Seed "raw_customers" failed in "raw"' These fire through the existing UserLevel → LogHelper(audience="user") → Celery → socket pipeline. Frontend filters them via the "User activity" dropdown option. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../core/routers/transformation/views.py | 18 ++++++++ backend/visitran/events/proto_types.py | 42 +++++++++++++++++++ backend/visitran/events/types.py | 38 +++++++++++++++++ backend/visitran/visitran.py | 7 ++++ 4 files changed, 105 insertions(+) diff --git a/backend/backend/core/routers/transformation/views.py b/backend/backend/core/routers/transformation/views.py index bf8b3f8..cd65f31 100644 --- a/backend/backend/core/routers/transformation/views.py +++ b/backend/backend/core/routers/transformation/views.py @@ -10,6 +10,8 @@ from backend.utils.cache_service.decorators.cache_decorator import clear_cache from backend.utils.constants import HTTPMethods from rbac.factory import handle_permission +from visitran.events.functions import fire_event +from visitran.events.types import TransformationApplied, TransformationDeleted, ModelConfigured RESOURCE_NAME = "configmodels" @@ -98,6 +100,14 @@ def set_model_config_and_reference( request_data, model_name=file_name ) response_json["status"] = "success" + model_config = request_data.get("model_config", {}) + src = model_config.get("source", {}) + dest = model_config.get("model", {}) + fire_event(ModelConfigured( + model_name=file_name, + source=f"{src.get('schema_name', '')}.{src.get('table_name', '')}", + destination=f"{dest.get('schema_name', '')}.{dest.get('table_name', '')}", + )) return Response(data=response_json) @@ -116,6 +126,10 @@ def set_model_transformation( request_data, model_name=file_name ) response_json["status"] = "success" + fire_event(TransformationApplied( + model_name=file_name, + transformation_type=request_data.get("type", "unknown"), + )) return Response(data=response_json) @@ -138,6 +152,10 @@ def delete_model_transformation( is_clear_all=is_clear_all, ) response_json["status"] = "success" + fire_event(TransformationDeleted( + model_name=file_name, + transformation_type="all" if is_clear_all else (transformation_id or "unknown"), + )) return Response(data=response_json) diff --git a/backend/visitran/events/proto_types.py b/backend/visitran/events/proto_types.py index 6cd1981..51572ee 100644 --- a/backend/visitran/events/proto_types.py +++ b/backend/visitran/events/proto_types.py @@ -1066,3 +1066,45 @@ class ModelRunFailed(betterproto.Message): class ModelRunFailedMsg(betterproto.Message): info: "EventInfo" = betterproto.message_field(1) data: "ModelRunFailed" = betterproto.message_field(2) + +@dataclass +class TransformationApplied(betterproto.Message): + model_name: str = betterproto.string_field(1) + transformation_type: str = betterproto.string_field(2) + +@dataclass +class TransformationAppliedMsg(betterproto.Message): + info: "EventInfo" = betterproto.message_field(1) + data: "TransformationApplied" = betterproto.message_field(2) + +@dataclass +class TransformationDeleted(betterproto.Message): + model_name: str = betterproto.string_field(1) + transformation_type: str = betterproto.string_field(2) + +@dataclass +class TransformationDeletedMsg(betterproto.Message): + info: "EventInfo" = betterproto.message_field(1) + data: "TransformationDeleted" = betterproto.message_field(2) + +@dataclass +class ModelConfigured(betterproto.Message): + model_name: str = betterproto.string_field(1) + source: str = betterproto.string_field(2) + destination: str = betterproto.string_field(3) + +@dataclass +class ModelConfiguredMsg(betterproto.Message): + info: "EventInfo" = betterproto.message_field(1) + data: "ModelConfigured" = betterproto.message_field(2) + +@dataclass +class SeedCompleted(betterproto.Message): + seed_name: str = betterproto.string_field(1) + schema_name: str = betterproto.string_field(2) + status: str = betterproto.string_field(3) + +@dataclass +class SeedCompletedMsg(betterproto.Message): + info: "EventInfo" = betterproto.message_field(1) + data: "SeedCompleted" = betterproto.message_field(2) diff --git a/backend/visitran/events/types.py b/backend/visitran/events/types.py index d295c01..73743f9 100644 --- a/backend/visitran/events/types.py +++ b/backend/visitran/events/types.py @@ -854,3 +854,41 @@ def code(self) -> str: def message(self) -> str: short_err = (self.error[:120] + "…") if len(self.error) > 120 else self.error return f'Model "{self.model_name}" failed: {short_err}' + + +@dataclass +class TransformationApplied(UserLevel, proto_type.TransformationApplied): + def code(self) -> str: + return "U004" + + def message(self) -> str: + return f'Applied {self.transformation_type} transformation on "{self.model_name}"' + + +@dataclass +class TransformationDeleted(UserLevel, proto_type.TransformationDeleted): + def code(self) -> str: + return "U005" + + def message(self) -> str: + return f'Removed {self.transformation_type} transformation from "{self.model_name}"' + + +@dataclass +class ModelConfigured(UserLevel, proto_type.ModelConfigured): + def code(self) -> str: + return "U006" + + def message(self) -> str: + return f'Configured "{self.model_name}" — source: {self.source}, destination: {self.destination}' + + +@dataclass +class SeedCompleted(UserLevel, proto_type.SeedCompleted): + def code(self) -> str: + return "U007" + + def message(self) -> str: + if self.status == "Success": + return f'Seed "{self.seed_name}" loaded into "{self.schema_name}"' + return f'Seed "{self.seed_name}" failed in "{self.schema_name}"' diff --git a/backend/visitran/visitran.py b/backend/visitran/visitran.py index 722fc68..f22c86d 100644 --- a/backend/visitran/visitran.py +++ b/backend/visitran/visitran.py @@ -78,6 +78,7 @@ def now(): ModelRunStarted, ModelRunSucceeded, ModelRunFailed, + SeedCompleted, ) from visitran.materialization import Materialization from visitran.singleton import Singleton @@ -851,6 +852,9 @@ def validate_and_run_seed(self, seed_file, session_id: str) -> dict[str, str]: ) SEED_RESULT.append(seed_result) parse_and_fire_seed_report() + fire_event(SeedCompleted( + seed_name=file_name, schema_name=schema, status="Success", + )) return { "file_name": file_name, "status": "Success", @@ -860,6 +864,9 @@ def validate_and_run_seed(self, seed_file, session_id: str) -> dict[str, str]: except Exception as err: # Catches all kind of errors and raises with custom exceptions for seeds fire_event(SeedExecutionError(file_name, str(err))) + fire_event(SeedCompleted( + seed_name=file_name, schema_name="", status="Failed", + )) logging.exception(f"validate and run seed failed with exception {err}") raise RunSeedFailedException(file_name=file_name, error_message=str(err)) From 6c7d17df0dbecb1b548e8dc54ec06a678dcadb21 Mon Sep 17 00:00:00 2001 From: abhizipstack Date: Thu, 16 Apr 2026 20:39:06 +0530 Subject: [PATCH 06/20] =?UTF-8?q?feat:=20add=20P2=20user-facing=20events?= =?UTF-8?q?=20=E2=80=94=20job=20scheduler=20operations?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 4 new UserLevel events for job lifecycle actions: - JobCreated (U008): fired after create_periodic_task succeeds. Message: 'Job "Nightly refresh" created for environment "prod"' - JobUpdated (U009): fired after update_periodic_task. Message: 'Job "Nightly refresh" updated' - JobDeleted (U010): fired after delete_periodic_task. Message: 'Job "Nightly refresh" deleted' - JobTriggered (U011): fired from _dispatch_task_run (covers both trigger_task_once and trigger_task_once_for_model). Message: 'Job "Nightly refresh" triggered manually — running all models' or '— running model mdoela' Co-Authored-By: Claude Opus 4.6 (1M context) --- backend/backend/core/scheduler/views.py | 14 +++++++++ backend/visitran/events/proto_types.py | 38 +++++++++++++++++++++++++ backend/visitran/events/types.py | 37 ++++++++++++++++++++++++ 3 files changed, 89 insertions(+) diff --git a/backend/backend/core/scheduler/views.py b/backend/backend/core/scheduler/views.py index c0c341e..66bba35 100644 --- a/backend/backend/core/scheduler/views.py +++ b/backend/backend/core/scheduler/views.py @@ -16,6 +16,8 @@ from backend.core.scheduler.serializer import TaskRunHistorySerializer from backend.core.scheduler.task_constant import Task, TaskStatus, TaskType from backend.utils.tenant_context import get_organization +from visitran.events.functions import fire_event +from visitran.events.types import JobCreated, JobUpdated, JobDeleted, JobTriggered logger = logging.getLogger(__name__) @@ -343,6 +345,10 @@ def create_periodic_task(request, project_id): ) periodic_task.save() + fire_event(JobCreated( + job_name=task_name, + environment_name=getattr(environment, "environment_name", ""), + )) return Response( {"status": "Task created successfully"}, status=status.HTTP_201_CREATED, @@ -530,6 +536,7 @@ def update_periodic_task(request, project_id, user_task_id): ) periodic_task.save(update_fields=["kwargs"]) + fire_event(JobUpdated(job_name=user_task.task_name)) return Response( {"status": "Task updated successfully"}, status=status.HTTP_200_OK, @@ -554,11 +561,13 @@ def delete_periodic_task(request, project_id, task_id): user_task = UserTaskDetails.objects.select_related( "periodic_task" ).get(periodic_task_id=task_id, project__project_uuid=project_id) + task_name = user_task.task_name periodic_task = user_task.periodic_task user_task.delete() if periodic_task: periodic_task.delete() + fire_event(JobDeleted(job_name=task_name)) return Response( {"status": "Task deleted successfully"}, status=status.HTTP_200_OK, @@ -630,6 +639,11 @@ def _dispatch_task_run(task, user_id, models_override=None): scheduler path hits ``trigger_scheduled_run`` without this dispatch wrapper, and it keeps the default ``trigger="scheduled"``. """ + scope = models_override[0] if models_override and len(models_override) == 1 else "job" + fire_event(JobTriggered( + job_name=task.task_name, + scope=scope, + )) run_kwargs = { "user_task_id": task.id, "user_id": user_id, diff --git a/backend/visitran/events/proto_types.py b/backend/visitran/events/proto_types.py index 51572ee..d21f1c4 100644 --- a/backend/visitran/events/proto_types.py +++ b/backend/visitran/events/proto_types.py @@ -1108,3 +1108,41 @@ class SeedCompleted(betterproto.Message): class SeedCompletedMsg(betterproto.Message): info: "EventInfo" = betterproto.message_field(1) data: "SeedCompleted" = betterproto.message_field(2) + +@dataclass +class JobCreated(betterproto.Message): + job_name: str = betterproto.string_field(1) + environment_name: str = betterproto.string_field(2) + +@dataclass +class JobCreatedMsg(betterproto.Message): + info: "EventInfo" = betterproto.message_field(1) + data: "JobCreated" = betterproto.message_field(2) + +@dataclass +class JobUpdated(betterproto.Message): + job_name: str = betterproto.string_field(1) + +@dataclass +class JobUpdatedMsg(betterproto.Message): + info: "EventInfo" = betterproto.message_field(1) + data: "JobUpdated" = betterproto.message_field(2) + +@dataclass +class JobDeleted(betterproto.Message): + job_name: str = betterproto.string_field(1) + +@dataclass +class JobDeletedMsg(betterproto.Message): + info: "EventInfo" = betterproto.message_field(1) + data: "JobDeleted" = betterproto.message_field(2) + +@dataclass +class JobTriggered(betterproto.Message): + job_name: str = betterproto.string_field(1) + scope: str = betterproto.string_field(2) + +@dataclass +class JobTriggeredMsg(betterproto.Message): + info: "EventInfo" = betterproto.message_field(1) + data: "JobTriggered" = betterproto.message_field(2) diff --git a/backend/visitran/events/types.py b/backend/visitran/events/types.py index 73743f9..f7efe33 100644 --- a/backend/visitran/events/types.py +++ b/backend/visitran/events/types.py @@ -892,3 +892,40 @@ def message(self) -> str: if self.status == "Success": return f'Seed "{self.seed_name}" loaded into "{self.schema_name}"' return f'Seed "{self.seed_name}" failed in "{self.schema_name}"' + + +@dataclass +class JobCreated(UserLevel, proto_type.JobCreated): + def code(self) -> str: + return "U008" + + def message(self) -> str: + return f'Job "{self.job_name}" created for environment "{self.environment_name}"' + + +@dataclass +class JobUpdated(UserLevel, proto_type.JobUpdated): + def code(self) -> str: + return "U009" + + def message(self) -> str: + return f'Job "{self.job_name}" updated' + + +@dataclass +class JobDeleted(UserLevel, proto_type.JobDeleted): + def code(self) -> str: + return "U010" + + def message(self) -> str: + return f'Job "{self.job_name}" deleted' + + +@dataclass +class JobTriggered(UserLevel, proto_type.JobTriggered): + def code(self) -> str: + return "U011" + + def message(self) -> str: + scope_desc = "all models" if self.scope == "job" else f"model {self.scope}" + return f'Job "{self.job_name}" triggered manually — running {scope_desc}' From a4293bae06b60675015f39a02e2bf7139500dede Mon Sep 17 00:00:00 2001 From: abhizipstack Date: Thu, 16 Apr 2026 20:44:24 +0530 Subject: [PATCH 07/20] =?UTF-8?q?feat:=20add=20P3-P5=20user-facing=20event?= =?UTF-8?q?s=20=E2=80=94=20CRUD,=20connections,=20environments?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 8 new UserLevel events covering model/file CRUD, connections, and environments: P3 — Model/project CRUD: - ModelCreated (U012): model created in explorer - FileDeleted (U013): files/models deleted - FileRenamed (U014): file/model renamed P4 — Connections: - ConnectionCreated (U015): new connection created - ConnectionTested (U016): connection test result - ConnectionDeletedEvt (U017): connection deleted P5 — Environments: - EnvironmentCreated (U018): new environment created - EnvironmentDeleted (U019): environment deleted All fire through the UserLevel → audience="user" pipeline and appear in the "User activity" log view. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../backend/core/routers/connection/views.py | 8 ++ .../backend/core/routers/environment/views.py | 6 ++ .../backend/core/routers/explorer/views.py | 6 ++ backend/visitran/events/proto_types.py | 75 ++++++++++++++++++ backend/visitran/events/types.py | 76 +++++++++++++++++++ 5 files changed, 171 insertions(+) diff --git a/backend/backend/core/routers/connection/views.py b/backend/backend/core/routers/connection/views.py index 0de8ddc..19f5ed3 100644 --- a/backend/backend/core/routers/connection/views.py +++ b/backend/backend/core/routers/connection/views.py @@ -10,6 +10,8 @@ from backend.core.utils import handle_http_request from backend.utils.constants import HTTPMethods from rbac.factory import handle_permission +from visitran.events.functions import fire_event +from visitran.events.types import ConnectionCreated, ConnectionTested, ConnectionDeletedEvt RESOURCE_NAME = "connectiondetails" @@ -42,6 +44,10 @@ def create_connection(request: Request) -> Response: connection_data = con_context.create_connection( connection_details=request_payload, force_create=bool(force_create) ) + fire_event(ConnectionCreated( + connection_name=request_payload.get("name", ""), + datasource=request_payload.get("datasource_name", ""), + )) response_data = {"status": "success", "data": connection_data} return Response(data=response_data, status=status.HTTP_200_OK) @@ -116,6 +122,7 @@ def connection_usage(request: Request, connection_id: str) -> Response: def delete_connection(request: Request, connection_id: str) -> Response: con_context = ConnectionContext() con_context.delete_connection(connection_id=connection_id) + fire_event(ConnectionDeletedEvt(connection_id=connection_id)) response_data = { "status": "success", "data": f"{connection_id} is deleted successfully.", @@ -158,4 +165,5 @@ def test_connection(request: Request) -> Response: ) connection_id: str = cast(str, request_data.get("connection_id", "")) or None con_context.test_connection(datasource=datasource, connection_data=connection_data, connection_id=connection_id) + fire_event(ConnectionTested(datasource=datasource, result="success")) return Response(data={"status": "success"}, status=status.HTTP_200_OK) diff --git a/backend/backend/core/routers/environment/views.py b/backend/backend/core/routers/environment/views.py index 87f6c16..662bd6c 100644 --- a/backend/backend/core/routers/environment/views.py +++ b/backend/backend/core/routers/environment/views.py @@ -11,6 +11,8 @@ from backend.core.utils import handle_http_request from backend.utils.constants import HTTPMethods from rbac.factory import handle_permission +from visitran.events.functions import fire_event +from visitran.events.types import EnvironmentCreated, EnvironmentDeleted RESOURCE_NAME = "environmentmodels" @@ -50,6 +52,9 @@ def create_environment(request) -> Response: env_data: dict[str, Any] = env_context.create_environment( environment_details=request_payload ) + fire_event(EnvironmentCreated( + environment_name=request_payload.get("name", ""), + )) response_data = {"status": "success", "data": env_data} return Response(data=response_data, status=status.HTTP_201_CREATED) @@ -74,6 +79,7 @@ def delete_environment(request: Request, environment_id: str): env_context = EnvironmentContext() try: env_context.delete_environment(environment_id=environment_id) + fire_event(EnvironmentDeleted(environment_id=environment_id)) response_data = {"status": "success"} return Response(data=response_data, status=status.HTTP_200_OK) except ProtectedError as e: diff --git a/backend/backend/core/routers/explorer/views.py b/backend/backend/core/routers/explorer/views.py index 1a05e8e..f8869af 100644 --- a/backend/backend/core/routers/explorer/views.py +++ b/backend/backend/core/routers/explorer/views.py @@ -10,6 +10,8 @@ from backend.core.utils import handle_http_request from backend.utils.cache_service.decorators.cache_decorator import clear_cache from backend.utils.constants import HTTPMethods +from visitran.events.functions import fire_event +from visitran.events.types import ModelCreated, FileDeleted, FileRenamed @api_view([HTTPMethods.GET]) @@ -52,6 +54,7 @@ def create_model_explorer(request: Request, project_id: str) -> Response: model_name = request_data.get("model_name", "").replace(" ", "_").strip() app = ApplicationContext(project_id=project_id) app.create_a_model(model_name=model_name, is_generate_ai_request=False) + fire_event(ModelCreated(model_name=model_name)) return Response(data={"status": "success"}, status=status.HTTP_200_OK) except FileExistsError: return Response( @@ -76,6 +79,7 @@ def delete_a_file_or_folder(request: Request, project_id: str) -> Response: app = ApplicationContext(project_id=project_id) if wipe_all_enabled: app.cleanup_no_code_model(table_delete_enabled=table_delete_enabled) + fire_event(FileDeleted(file_names="all models")) response_json = {"status": "success", "message": f"successfully deleted all model files"} else: # Build set of model names being deleted in this batch so that @@ -95,6 +99,7 @@ def delete_a_file_or_folder(request: Request, project_id: str) -> Response: ) deleted_files.append(file_name) + fire_event(FileDeleted(file_names=", ".join(deleted_files))) response_json = {"status": "success", "message": f"successfully deleted files {deleted_files}"} return Response(data=response_json) @@ -109,6 +114,7 @@ def rename_a_file_or_folder(request: Request, project_id: str) -> Response: rename: str = request_data["rename"] app = ApplicationContext(project_id=project_id) refactored_models = app.rename_a_file_or_folder(file_path=file_name, rename=rename) + fire_event(FileRenamed(old_name=file_name, new_name=rename)) response_json = {"status": "success", "refactored_models": refactored_models} return Response(data=response_json) diff --git a/backend/visitran/events/proto_types.py b/backend/visitran/events/proto_types.py index d21f1c4..0bfc01b 100644 --- a/backend/visitran/events/proto_types.py +++ b/backend/visitran/events/proto_types.py @@ -1146,3 +1146,78 @@ class JobTriggered(betterproto.Message): class JobTriggeredMsg(betterproto.Message): info: "EventInfo" = betterproto.message_field(1) data: "JobTriggered" = betterproto.message_field(2) + +@dataclass +class ModelCreated(betterproto.Message): + model_name: str = betterproto.string_field(1) + +@dataclass +class ModelCreatedMsg(betterproto.Message): + info: "EventInfo" = betterproto.message_field(1) + data: "ModelCreated" = betterproto.message_field(2) + +@dataclass +class FileDeleted(betterproto.Message): + file_names: str = betterproto.string_field(1) + +@dataclass +class FileDeletedMsg(betterproto.Message): + info: "EventInfo" = betterproto.message_field(1) + data: "FileDeleted" = betterproto.message_field(2) + +@dataclass +class FileRenamed(betterproto.Message): + old_name: str = betterproto.string_field(1) + new_name: str = betterproto.string_field(2) + +@dataclass +class FileRenamedMsg(betterproto.Message): + info: "EventInfo" = betterproto.message_field(1) + data: "FileRenamed" = betterproto.message_field(2) + +@dataclass +class ConnectionCreated(betterproto.Message): + connection_name: str = betterproto.string_field(1) + datasource: str = betterproto.string_field(2) + +@dataclass +class ConnectionCreatedMsg(betterproto.Message): + info: "EventInfo" = betterproto.message_field(1) + data: "ConnectionCreated" = betterproto.message_field(2) + +@dataclass +class ConnectionTested(betterproto.Message): + datasource: str = betterproto.string_field(1) + result: str = betterproto.string_field(2) + +@dataclass +class ConnectionTestedMsg(betterproto.Message): + info: "EventInfo" = betterproto.message_field(1) + data: "ConnectionTested" = betterproto.message_field(2) + +@dataclass +class ConnectionDeletedEvt(betterproto.Message): + connection_id: str = betterproto.string_field(1) + +@dataclass +class ConnectionDeletedEvtMsg(betterproto.Message): + info: "EventInfo" = betterproto.message_field(1) + data: "ConnectionDeletedEvt" = betterproto.message_field(2) + +@dataclass +class EnvironmentCreated(betterproto.Message): + environment_name: str = betterproto.string_field(1) + +@dataclass +class EnvironmentCreatedMsg(betterproto.Message): + info: "EventInfo" = betterproto.message_field(1) + data: "EnvironmentCreated" = betterproto.message_field(2) + +@dataclass +class EnvironmentDeleted(betterproto.Message): + environment_id: str = betterproto.string_field(1) + +@dataclass +class EnvironmentDeletedMsg(betterproto.Message): + info: "EventInfo" = betterproto.message_field(1) + data: "EnvironmentDeleted" = betterproto.message_field(2) diff --git a/backend/visitran/events/types.py b/backend/visitran/events/types.py index f7efe33..7e743b5 100644 --- a/backend/visitran/events/types.py +++ b/backend/visitran/events/types.py @@ -929,3 +929,79 @@ def code(self) -> str: def message(self) -> str: scope_desc = "all models" if self.scope == "job" else f"model {self.scope}" return f'Job "{self.job_name}" triggered manually — running {scope_desc}' + + +# ─── P3: Model/project CRUD ────────────────────────────────────────── + +@dataclass +class ModelCreated(UserLevel, proto_type.ModelCreated): + def code(self) -> str: + return "U012" + + def message(self) -> str: + return f'Model "{self.model_name}" created' + + +@dataclass +class FileDeleted(UserLevel, proto_type.FileDeleted): + def code(self) -> str: + return "U013" + + def message(self) -> str: + return f'Deleted: {self.file_names}' + + +@dataclass +class FileRenamed(UserLevel, proto_type.FileRenamed): + def code(self) -> str: + return "U014" + + def message(self) -> str: + return f'Renamed "{self.old_name}" → "{self.new_name}"' + + +# ─── P4: Connection & environment ──────────────────────────────────── + +@dataclass +class ConnectionCreated(UserLevel, proto_type.ConnectionCreated): + def code(self) -> str: + return "U015" + + def message(self) -> str: + return f'Connection created ({self.datasource})' + + +@dataclass +class ConnectionTested(UserLevel, proto_type.ConnectionTested): + def code(self) -> str: + return "U016" + + def message(self) -> str: + return f'Connection test: {self.result} ({self.datasource})' + + +@dataclass +class ConnectionDeletedEvt(UserLevel, proto_type.ConnectionDeletedEvt): + def code(self) -> str: + return "U017" + + def message(self) -> str: + return f'Connection "{self.connection_id}" deleted' + + +@dataclass +class EnvironmentCreated(UserLevel, proto_type.EnvironmentCreated): + def code(self) -> str: + return "U018" + + def message(self) -> str: + return f'Environment "{self.environment_name}" created' + + +@dataclass +class EnvironmentDeleted(UserLevel, proto_type.EnvironmentDeleted): + def code(self) -> str: + return "U019" + + def message(self) -> str: + return f'Environment "{self.environment_id}" deleted' From 2c845c247931056f9a0cc233823ce2f9a7a0e3b0 Mon Sep 17 00:00:00 2001 From: abhizipstack Date: Thu, 16 Apr 2026 20:47:31 +0530 Subject: [PATCH 08/20] fix: show names instead of IDs in connection/environment delete events ConnectionDeletedEvt and EnvironmentDeleted were passing raw UUIDs. Now fetch the name before deleting so the user-activity log shows 'Connection "my_postgres" deleted' instead of 'Connection "uuid" deleted'. Co-Authored-By: Claude Opus 4.6 (1M context) --- backend/backend/core/routers/connection/views.py | 4 +++- backend/backend/core/routers/environment/views.py | 9 ++++++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/backend/backend/core/routers/connection/views.py b/backend/backend/core/routers/connection/views.py index 19f5ed3..17d27c1 100644 --- a/backend/backend/core/routers/connection/views.py +++ b/backend/backend/core/routers/connection/views.py @@ -121,8 +121,10 @@ def connection_usage(request: Request, connection_id: str) -> Response: @handle_permission def delete_connection(request: Request, connection_id: str) -> Response: con_context = ConnectionContext() + conn_data = con_context.get_connection(connection_id=connection_id) + conn_name = conn_data.get("name", connection_id) if conn_data else connection_id con_context.delete_connection(connection_id=connection_id) - fire_event(ConnectionDeletedEvt(connection_id=connection_id)) + fire_event(ConnectionDeletedEvt(connection_id=conn_name)) response_data = { "status": "success", "data": f"{connection_id} is deleted successfully.", diff --git a/backend/backend/core/routers/environment/views.py b/backend/backend/core/routers/environment/views.py index 662bd6c..526296b 100644 --- a/backend/backend/core/routers/environment/views.py +++ b/backend/backend/core/routers/environment/views.py @@ -78,8 +78,15 @@ def update_environment(request, environment_id: str) -> Response: def delete_environment(request: Request, environment_id: str): env_context = EnvironmentContext() try: + from backend.core.models.environment_models import EnvironmentModels + env_name = environment_id + try: + env_obj = EnvironmentModels.objects.get(environment_id=environment_id) + env_name = env_obj.environment_name or environment_id + except EnvironmentModels.DoesNotExist: + pass env_context.delete_environment(environment_id=environment_id) - fire_event(EnvironmentDeleted(environment_id=environment_id)) + fire_event(EnvironmentDeleted(environment_id=env_name)) response_data = {"status": "success"} return Response(data=response_data, status=status.HTTP_200_OK) except ProtectedError as e: From f9c45eb79f65993ea36827d280df82e6c483fb08 Mon Sep 17 00:00:00 2001 From: abhizipstack Date: Thu, 16 Apr 2026 21:00:06 +0530 Subject: [PATCH 09/20] fix: environment delete shows proper error when used by a job Backend returned {"message": "..."} on ProtectedError but the frontend notification service reads "error_message". Changed key to "error_message" for consistency with other endpoints. Also improved the error message copy and added explicit error_message extraction in the frontend catch block so the user sees: "Cannot delete this environment because it is used by: Nightly from 'Deploy'. Remove it from the job first, then delete." instead of the generic "Something went wrong". Co-Authored-By: Claude Opus 4.6 (1M context) --- .../backend/core/routers/environment/views.py | 55 ++++++++----------- backend/backend/errors/error_codes.py | 7 +++ .../backend/errors/validation_exceptions.py | 18 ++++++ 3 files changed, 48 insertions(+), 32 deletions(-) diff --git a/backend/backend/core/routers/environment/views.py b/backend/backend/core/routers/environment/views.py index 526296b..d072210 100644 --- a/backend/backend/core/routers/environment/views.py +++ b/backend/backend/core/routers/environment/views.py @@ -76,42 +76,33 @@ def update_environment(request, environment_id: str) -> Response: @handle_http_request @handle_permission def delete_environment(request: Request, environment_id: str): + from backend.core.models.environment_models import EnvironmentModels + from backend.errors.validation_exceptions import EnvironmentInUse + env_context = EnvironmentContext() + env_name = environment_id + try: + env_obj = EnvironmentModels.objects.get(environment_id=environment_id) + env_name = env_obj.environment_name or environment_id + except EnvironmentModels.DoesNotExist: + pass + try: - from backend.core.models.environment_models import EnvironmentModels - env_name = environment_id - try: - env_obj = EnvironmentModels.objects.get(environment_id=environment_id) - env_name = env_obj.environment_name or environment_id - except EnvironmentModels.DoesNotExist: - pass env_context.delete_environment(environment_id=environment_id) - fire_event(EnvironmentDeleted(environment_id=env_name)) - response_data = {"status": "success"} - return Response(data=response_data, status=status.HTTP_200_OK) except ProtectedError as e: - protected_objects = e.protected_objects - blocked_apps = set() - blocked_data = {} - for obj in protected_objects: - app_name = obj._meta.label.split(".")[ - 0 - ] # Extracts "appname. model_name can also be extracted like _meta.model_name" - if app_name == "job_scheduler": - key = "Deploy" - if key not in blocked_data: - blocked_data[key] = [] - blocked_data[key] = obj.task_name - blocked_apps.add(app_name) - error_details = [] - for model, ids in blocked_data.items(): - error_details.append(f"{ids} from '{model}'") - error_message = f"Cannot delete this environment record because it is referenced by: {', '.join(error_details)}." - data = { - "message": error_message, - "status": "failed", - } - return Response(data=data, status=status.HTTP_400_BAD_REQUEST) + job_names = [ + obj.task_name + for obj in e.protected_objects + if obj._meta.label.split(".")[0] == "job_scheduler" + ] + raise EnvironmentInUse( + environment_name=env_name, + job_names=", ".join(job_names) if job_names else "unknown", + ) + + fire_event(EnvironmentDeleted(environment_id=env_name)) + response_data = {"status": "success"} + return Response(data=response_data, status=status.HTTP_200_OK) @api_view([HTTPMethods.GET]) diff --git a/backend/backend/errors/error_codes.py b/backend/backend/errors/error_codes.py index a5075ca..7b6fcde 100644 --- a/backend/backend/errors/error_codes.py +++ b/backend/backend/errors/error_codes.py @@ -87,6 +87,13 @@ class BackendErrorMessages(BaseConstant): "\nPlease delete the projects or **ask for a feature to modify the connections in projects** and retry." ) + ENVIRONMENT_IN_USE = ( + '### **Environment In Use!**\n' + 'Environment **"{environment_name}"** cannot be deleted because it is ' + 'referenced by the following job(s): **{job_names}**.\n\n' + 'Please remove the environment from these jobs first, then delete.' + ) + MODEL_ALREADY_EXISTS = ( '**Model Exists!**\nModel "{model_name}" already created at {created_at}. ' "Choose a unique name or delete the existing one." diff --git a/backend/backend/errors/validation_exceptions.py b/backend/backend/errors/validation_exceptions.py index 2935cec..f82b4bf 100644 --- a/backend/backend/errors/validation_exceptions.py +++ b/backend/backend/errors/validation_exceptions.py @@ -138,3 +138,21 @@ def __init__(self, prohibited_action: str, prohibited_actions: list[str]) -> Non prohibited_action=prohibited_action, prohibited_actions=prohibited_actions, ) + + +class EnvironmentInUse(VisitranBackendBaseException): + """ + Raised when trying to delete an environment that is referenced by scheduled jobs. + """ + + def __init__(self, environment_name: str, job_names: str) -> None: + super().__init__( + error_code=BackendErrorMessages.ENVIRONMENT_IN_USE, + http_status_code=status.HTTP_400_BAD_REQUEST, + environment_name=environment_name, + job_names=job_names, + ) + + @property + def severity(self) -> str: + return "Warning" From 22166216d4f10a74144e14e3fe406dcea51e94cc Mon Sep 17 00:00:00 2001 From: abhizipstack Date: Thu, 16 Apr 2026 21:32:50 +0530 Subject: [PATCH 10/20] =?UTF-8?q?fix:=20address=20Greptile=20review=20?= =?UTF-8?q?=E2=80=94=20name=20lookup=20safety,=20seed=20schema,=20field=20?= =?UTF-8?q?names?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P1: Connection name-lookup before delete could block deletion on transient errors. Wrapped in try/except so deletion proceeds regardless; event falls back to connection_id. P2: SeedCompleted failure path had schema_name="". Now reads from self.context.schema_name with fallback to empty string. P2: Renamed misleading proto fields — connection_id → connection_name and environment_id → environment_name since they carry display names, not UUIDs. Updated event classes and all callers. Co-Authored-By: Claude Opus 4.6 (1M context) --- backend/backend/core/routers/connection/views.py | 10 +++++++--- backend/backend/core/routers/environment/views.py | 2 +- backend/visitran/events/proto_types.py | 4 ++-- backend/visitran/events/types.py | 4 ++-- backend/visitran/visitran.py | 4 +++- 5 files changed, 15 insertions(+), 9 deletions(-) diff --git a/backend/backend/core/routers/connection/views.py b/backend/backend/core/routers/connection/views.py index 17d27c1..ca131ba 100644 --- a/backend/backend/core/routers/connection/views.py +++ b/backend/backend/core/routers/connection/views.py @@ -121,10 +121,14 @@ def connection_usage(request: Request, connection_id: str) -> Response: @handle_permission def delete_connection(request: Request, connection_id: str) -> Response: con_context = ConnectionContext() - conn_data = con_context.get_connection(connection_id=connection_id) - conn_name = conn_data.get("name", connection_id) if conn_data else connection_id + conn_name = connection_id + try: + conn_data = con_context.get_connection(connection_id=connection_id) + conn_name = conn_data.get("name", connection_id) if conn_data else connection_id + except Exception: + pass # Best-effort name lookup; deletion proceeds regardless con_context.delete_connection(connection_id=connection_id) - fire_event(ConnectionDeletedEvt(connection_id=conn_name)) + fire_event(ConnectionDeletedEvt(connection_name=conn_name)) response_data = { "status": "success", "data": f"{connection_id} is deleted successfully.", diff --git a/backend/backend/core/routers/environment/views.py b/backend/backend/core/routers/environment/views.py index d072210..baa9a3f 100644 --- a/backend/backend/core/routers/environment/views.py +++ b/backend/backend/core/routers/environment/views.py @@ -100,7 +100,7 @@ def delete_environment(request: Request, environment_id: str): job_names=", ".join(job_names) if job_names else "unknown", ) - fire_event(EnvironmentDeleted(environment_id=env_name)) + fire_event(EnvironmentDeleted(environment_name=env_name)) response_data = {"status": "success"} return Response(data=response_data, status=status.HTTP_200_OK) diff --git a/backend/visitran/events/proto_types.py b/backend/visitran/events/proto_types.py index 0bfc01b..5c290c8 100644 --- a/backend/visitran/events/proto_types.py +++ b/backend/visitran/events/proto_types.py @@ -1197,7 +1197,7 @@ class ConnectionTestedMsg(betterproto.Message): @dataclass class ConnectionDeletedEvt(betterproto.Message): - connection_id: str = betterproto.string_field(1) + connection_name: str = betterproto.string_field(1) @dataclass class ConnectionDeletedEvtMsg(betterproto.Message): @@ -1215,7 +1215,7 @@ class EnvironmentCreatedMsg(betterproto.Message): @dataclass class EnvironmentDeleted(betterproto.Message): - environment_id: str = betterproto.string_field(1) + environment_name: str = betterproto.string_field(1) @dataclass class EnvironmentDeletedMsg(betterproto.Message): diff --git a/backend/visitran/events/types.py b/backend/visitran/events/types.py index 7e743b5..72d8740 100644 --- a/backend/visitran/events/types.py +++ b/backend/visitran/events/types.py @@ -986,7 +986,7 @@ def code(self) -> str: return "U017" def message(self) -> str: - return f'Connection "{self.connection_id}" deleted' + return f'Connection "{self.connection_name}" deleted' @dataclass @@ -1004,4 +1004,4 @@ def code(self) -> str: return "U019" def message(self) -> str: - return f'Environment "{self.environment_id}" deleted' + return f'Environment "{self.environment_name}" deleted' diff --git a/backend/visitran/visitran.py b/backend/visitran/visitran.py index f22c86d..77d28fc 100644 --- a/backend/visitran/visitran.py +++ b/backend/visitran/visitran.py @@ -865,7 +865,9 @@ def validate_and_run_seed(self, seed_file, session_id: str) -> dict[str, str]: # Catches all kind of errors and raises with custom exceptions for seeds fire_event(SeedExecutionError(file_name, str(err))) fire_event(SeedCompleted( - seed_name=file_name, schema_name="", status="Failed", + seed_name=file_name, + schema_name=getattr(self.context, "schema_name", "") or "", + status="Failed", )) logging.exception(f"validate and run seed failed with exception {err}") raise RunSeedFailedException(file_name=file_name, error_message=str(err)) From e382abeeb4edacda8888743465c4500c11eddaec Mon Sep 17 00:00:00 2001 From: abhizipstack Date: Thu, 16 Apr 2026 21:35:42 +0530 Subject: [PATCH 11/20] =?UTF-8?q?fix:=20restructure=20delete=5Fconnection?= =?UTF-8?q?=20=E2=80=94=20fetch=20+=20delete=20in=20same=20try=20block?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit If fetching the connection fails (doesn't exist, DB down), no point attempting deletion. Both operations now live in one try block. fire_event fires from both success and exception paths so the activity log captures the attempt either way. Exception re-raises so handle_http_request returns the proper error to the frontend. Co-Authored-By: Claude Opus 4.6 (1M context) --- backend/backend/core/routers/connection/views.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/backend/backend/core/routers/connection/views.py b/backend/backend/core/routers/connection/views.py index ca131ba..3da39dd 100644 --- a/backend/backend/core/routers/connection/views.py +++ b/backend/backend/core/routers/connection/views.py @@ -125,13 +125,14 @@ def delete_connection(request: Request, connection_id: str) -> Response: try: conn_data = con_context.get_connection(connection_id=connection_id) conn_name = conn_data.get("name", connection_id) if conn_data else connection_id + con_context.delete_connection(connection_id=connection_id) + fire_event(ConnectionDeletedEvt(connection_name=conn_name)) except Exception: - pass # Best-effort name lookup; deletion proceeds regardless - con_context.delete_connection(connection_id=connection_id) - fire_event(ConnectionDeletedEvt(connection_name=conn_name)) + fire_event(ConnectionDeletedEvt(connection_name=conn_name)) + raise response_data = { "status": "success", - "data": f"{connection_id} is deleted successfully.", + "data": f"{conn_name} is deleted successfully.", } return Response(data=response_data, status=status.HTTP_200_OK) From 66f1d53c681d34d1481b2618871052025c7b5aa8 Mon Sep 17 00:00:00 2001 From: abhizipstack Date: Thu, 16 Apr 2026 21:38:44 +0530 Subject: [PATCH 12/20] fix: raise ConnectionDeleteFailed exception with proper formatting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces bare re-raise with a dedicated ConnectionDeleteFailed exception following the same pattern as EnvironmentInUse — BackendErrorMessages template with markdown formatting, caught by handle_http_request decorator for uniform error response. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../backend/core/routers/connection/views.py | 9 +++++++-- backend/backend/errors/error_codes.py | 6 ++++++ .../backend/errors/validation_exceptions.py | 18 ++++++++++++++++++ 3 files changed, 31 insertions(+), 2 deletions(-) diff --git a/backend/backend/core/routers/connection/views.py b/backend/backend/core/routers/connection/views.py index 3da39dd..c459b11 100644 --- a/backend/backend/core/routers/connection/views.py +++ b/backend/backend/core/routers/connection/views.py @@ -120,6 +120,8 @@ def connection_usage(request: Request, connection_id: str) -> Response: @handle_http_request @handle_permission def delete_connection(request: Request, connection_id: str) -> Response: + from backend.errors.validation_exceptions import ConnectionDeleteFailed + con_context = ConnectionContext() conn_name = connection_id try: @@ -127,9 +129,12 @@ def delete_connection(request: Request, connection_id: str) -> Response: conn_name = conn_data.get("name", connection_id) if conn_data else connection_id con_context.delete_connection(connection_id=connection_id) fire_event(ConnectionDeletedEvt(connection_name=conn_name)) - except Exception: + except Exception as e: fire_event(ConnectionDeletedEvt(connection_name=conn_name)) - raise + raise ConnectionDeleteFailed( + connection_name=conn_name, + reason=str(e), + ) response_data = { "status": "success", "data": f"{conn_name} is deleted successfully.", diff --git a/backend/backend/errors/error_codes.py b/backend/backend/errors/error_codes.py index 7b6fcde..b61f5a1 100644 --- a/backend/backend/errors/error_codes.py +++ b/backend/backend/errors/error_codes.py @@ -87,6 +87,12 @@ class BackendErrorMessages(BaseConstant): "\nPlease delete the projects or **ask for a feature to modify the connections in projects** and retry." ) + CONNECTION_DELETE_FAILED = ( + '### **Connection Delete Failed!**\n' + 'Unable to delete connection **"{connection_name}"**.\n\n' + 'Reason: {reason}' + ) + ENVIRONMENT_IN_USE = ( '### **Environment In Use!**\n' 'Environment **"{environment_name}"** cannot be deleted because it is ' diff --git a/backend/backend/errors/validation_exceptions.py b/backend/backend/errors/validation_exceptions.py index f82b4bf..0dbe17b 100644 --- a/backend/backend/errors/validation_exceptions.py +++ b/backend/backend/errors/validation_exceptions.py @@ -156,3 +156,21 @@ def __init__(self, environment_name: str, job_names: str) -> None: @property def severity(self) -> str: return "Warning" + + +class ConnectionDeleteFailed(VisitranBackendBaseException): + """ + Raised when a connection cannot be deleted. + """ + + def __init__(self, connection_name: str, reason: str) -> None: + super().__init__( + error_code=BackendErrorMessages.CONNECTION_DELETE_FAILED, + http_status_code=status.HTTP_400_BAD_REQUEST, + connection_name=connection_name, + reason=reason, + ) + + @property + def severity(self) -> str: + return "Warning" From 4b708f8ba7971d6b6eba2082c52ec5d71b9be38a Mon Sep 17 00:00:00 2001 From: abhizipstack Date: Thu, 16 Apr 2026 21:42:44 +0530 Subject: [PATCH 13/20] fix: separate success and failure events for connection delete Success fires ConnectionDeletedEvt (U017, UserLevel/info): 'Connection "my_postgres" deleted' Failure fires ConnectionDeleteFailedEvt (U020, UserLevel/error): 'Failed to delete connection "my_postgres": reason...' The failure event uses level_tag=ERROR so it renders in red in the activity log, clearly distinguishing it from a successful delete. Co-Authored-By: Claude Opus 4.6 (1M context) --- backend/backend/core/routers/connection/views.py | 4 ++-- backend/visitran/events/proto_types.py | 10 ++++++++++ backend/visitran/events/types.py | 15 ++++++++++++++- 3 files changed, 26 insertions(+), 3 deletions(-) diff --git a/backend/backend/core/routers/connection/views.py b/backend/backend/core/routers/connection/views.py index c459b11..f12a4cb 100644 --- a/backend/backend/core/routers/connection/views.py +++ b/backend/backend/core/routers/connection/views.py @@ -11,7 +11,7 @@ from backend.utils.constants import HTTPMethods from rbac.factory import handle_permission from visitran.events.functions import fire_event -from visitran.events.types import ConnectionCreated, ConnectionTested, ConnectionDeletedEvt +from visitran.events.types import ConnectionCreated, ConnectionTested, ConnectionDeletedEvt, ConnectionDeleteFailedEvt RESOURCE_NAME = "connectiondetails" @@ -130,7 +130,7 @@ def delete_connection(request: Request, connection_id: str) -> Response: con_context.delete_connection(connection_id=connection_id) fire_event(ConnectionDeletedEvt(connection_name=conn_name)) except Exception as e: - fire_event(ConnectionDeletedEvt(connection_name=conn_name)) + fire_event(ConnectionDeleteFailedEvt(connection_name=conn_name, reason=str(e))) raise ConnectionDeleteFailed( connection_name=conn_name, reason=str(e), diff --git a/backend/visitran/events/proto_types.py b/backend/visitran/events/proto_types.py index 5c290c8..0935f53 100644 --- a/backend/visitran/events/proto_types.py +++ b/backend/visitran/events/proto_types.py @@ -1221,3 +1221,13 @@ class EnvironmentDeleted(betterproto.Message): class EnvironmentDeletedMsg(betterproto.Message): info: "EventInfo" = betterproto.message_field(1) data: "EnvironmentDeleted" = betterproto.message_field(2) + +@dataclass +class ConnectionDeleteFailed(betterproto.Message): + connection_name: str = betterproto.string_field(1) + reason: str = betterproto.string_field(2) + +@dataclass +class ConnectionDeleteFailedMsg(betterproto.Message): + info: "EventInfo" = betterproto.message_field(1) + data: "ConnectionDeleteFailed" = betterproto.message_field(2) diff --git a/backend/visitran/events/types.py b/backend/visitran/events/types.py index 72d8740..e90c061 100644 --- a/backend/visitran/events/types.py +++ b/backend/visitran/events/types.py @@ -3,7 +3,7 @@ from dataclasses import dataclass import visitran.events.proto_types as proto_type -from visitran.events.base_types import DebugLevel, ErrorLevel, InfoLevel, UserLevel, WarnLevel +from visitran.events.base_types import DebugLevel, ErrorLevel, EventLevel, InfoLevel, UserLevel, WarnLevel # # | Code | Description | @@ -989,6 +989,19 @@ def message(self) -> str: return f'Connection "{self.connection_name}" deleted' +@dataclass +class ConnectionDeleteFailedEvt(UserLevel, proto_type.ConnectionDeleteFailed): + def code(self) -> str: + return "U020" + + def level_tag(self) -> EventLevel: + return EventLevel.ERROR + + def message(self) -> str: + short = (self.reason[:120] + "…") if len(self.reason) > 120 else self.reason + return f'Failed to delete connection "{self.connection_name}": {short}' + + @dataclass class EnvironmentCreated(UserLevel, proto_type.EnvironmentCreated): def code(self) -> str: From d0bbd39a01d06fecb33e7c061d76d2dfac6abd02 Mon Sep 17 00:00:00 2001 From: abhizipstack Date: Fri, 17 Apr 2026 10:56:49 +0530 Subject: [PATCH 14/20] fix: move success fire_event outside try to prevent false failure fire_event inside try could throw (e.g., logger error) after delete_connection succeeded, triggering the except block and raising ConnectionDeleteFailed for a successful deletion. Moved success event after the try block. Co-Authored-By: Claude Opus 4.6 (1M context) --- backend/backend/core/routers/connection/views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/backend/core/routers/connection/views.py b/backend/backend/core/routers/connection/views.py index f12a4cb..5af16e3 100644 --- a/backend/backend/core/routers/connection/views.py +++ b/backend/backend/core/routers/connection/views.py @@ -128,13 +128,13 @@ def delete_connection(request: Request, connection_id: str) -> Response: conn_data = con_context.get_connection(connection_id=connection_id) conn_name = conn_data.get("name", connection_id) if conn_data else connection_id con_context.delete_connection(connection_id=connection_id) - fire_event(ConnectionDeletedEvt(connection_name=conn_name)) except Exception as e: fire_event(ConnectionDeleteFailedEvt(connection_name=conn_name, reason=str(e))) raise ConnectionDeleteFailed( connection_name=conn_name, reason=str(e), ) + fire_event(ConnectionDeletedEvt(connection_name=conn_name)) response_data = { "status": "success", "data": f"{conn_name} is deleted successfully.", From ab08e61bb507c59d9c54c1dcae95d5b97085c9a5 Mon Sep 17 00:00:00 2001 From: abhizipstack Date: Fri, 17 Apr 2026 14:15:44 +0530 Subject: [PATCH 15/20] fix: fire JobTriggered after successful dispatch, not before JobTriggered was fired optimistically before send_task/sync execution. If both dispatch paths failed, the activity log showed "triggered" for a job that never ran. Moved fire_event to after each successful dispatch, consistent with all other events in this PR. Co-Authored-By: Claude Opus 4.6 (1M context) --- backend/backend/core/scheduler/views.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/backend/backend/core/scheduler/views.py b/backend/backend/core/scheduler/views.py index 66bba35..aa09f6f 100644 --- a/backend/backend/core/scheduler/views.py +++ b/backend/backend/core/scheduler/views.py @@ -640,10 +640,6 @@ def _dispatch_task_run(task, user_id, models_override=None): wrapper, and it keeps the default ``trigger="scheduled"``. """ scope = models_override[0] if models_override and len(models_override) == 1 else "job" - fire_event(JobTriggered( - job_name=task.task_name, - scope=scope, - )) run_kwargs = { "user_task_id": task.id, "user_id": user_id, @@ -663,6 +659,7 @@ def _dispatch_task_run(task, user_id, models_override=None): task.task_run_time = timezone.now() task.save(update_fields=["status", "task_run_time"]) + fire_event(JobTriggered(job_name=task.task_name, scope=scope)) return Response( {"success": True, "data": "Job submitted to Celery broker."}, status=status.HTTP_200_OK, @@ -675,6 +672,7 @@ def _dispatch_task_run(task, user_id, models_override=None): trigger_scheduled_run(**run_kwargs) + fire_event(JobTriggered(job_name=task.task_name, scope=scope)) return Response( {"success": True, "data": "Job executed synchronously (no broker)."}, status=status.HTTP_200_OK, From 8553e8d36c260edb1cdde621cbe58f1f6e83e55d Mon Sep 17 00:00:00 2001 From: abhizipstack Date: Fri, 17 Apr 2026 14:53:15 +0530 Subject: [PATCH 16/20] fix: improve activity log readability and celery log queue - Use materialization .name instead of .value so logs show "TABLE"/"VIEW" instead of integer values like "1"/"2" - Extract transformation type from step_config (where frontend sends it) instead of top-level request data, fixing "Applied unknown transformation" - Add celery_log_task_queue to docker-compose celery worker so activity log events are actually consumed and delivered via WebSocket Co-Authored-By: Claude Opus 4.6 (1M context) --- backend/backend/core/routers/transformation/views.py | 4 +++- backend/visitran/visitran.py | 2 +- docker/docker-compose.yaml | 2 +- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/backend/backend/core/routers/transformation/views.py b/backend/backend/core/routers/transformation/views.py index cd65f31..b0787de 100644 --- a/backend/backend/core/routers/transformation/views.py +++ b/backend/backend/core/routers/transformation/views.py @@ -126,9 +126,11 @@ def set_model_transformation( request_data, model_name=file_name ) response_json["status"] = "success" + step_config = request_data.get("step_config", {}) + transformation_type = step_config.get("type", "unknown") if isinstance(step_config, dict) else "unknown" fire_event(TransformationApplied( model_name=file_name, - transformation_type=request_data.get("type", "unknown"), + transformation_type=transformation_type, )) return Response(data=response_json) diff --git a/backend/visitran/visitran.py b/backend/visitran/visitran.py index 77d28fc..4ccee4c 100644 --- a/backend/visitran/visitran.py +++ b/backend/visitran/visitran.py @@ -318,7 +318,7 @@ def execute_graph(self) -> None: continue _model_display = getattr(node, "destination_table_name", "") or str(node_name) - _mat = node.materialization.value if hasattr(node.materialization, "value") else str(node.materialization) + _mat = node.materialization.name if hasattr(node.materialization, "name") else str(node.materialization) fire_event( ModelRunStarted( model_name=_model_display, diff --git a/docker/docker-compose.yaml b/docker/docker-compose.yaml index 422ca4b..720ee86 100644 --- a/docker/docker-compose.yaml +++ b/docker/docker-compose.yaml @@ -54,7 +54,7 @@ services: container_name: visitran-celery-worker restart: unless-stopped entrypoint: .venv/bin/celery - command: "-A backend worker --loglevel=info -Q celery --concurrency=2" + command: "-A backend worker --loglevel=info -Q celery,celery_log_task_queue --concurrency=2" env_file: - ../backend/.env volumes: From 51938082e221d84831f40bdf333ea3fecf949726 Mon Sep 17 00:00:00 2001 From: abhizipstack Date: Fri, 17 Apr 2026 16:54:34 +0530 Subject: [PATCH 17/20] feat: structured activity feed for user-facing logs Replace raw text logs with structured data for user-level events: - Send title, subtitle, status, timestamp as separate fields - Render as activity cards with status icons and color-coded borders - Remove [ThreadPool] prefix from developer logs Co-Authored-By: Claude Opus 4.6 (1M context) --- backend/visitran/events/base_types.py | 12 ++ backend/visitran/events/eventmgr.py | 25 +++- backend/visitran/events/types.py | 132 ++++++++++++++++++ .../editor/no-code-model/no-code-model.jsx | 99 +++++++++++-- 4 files changed, 250 insertions(+), 18 deletions(-) diff --git a/backend/visitran/events/base_types.py b/backend/visitran/events/base_types.py index 7109820..1cbca4d 100644 --- a/backend/visitran/events/base_types.py +++ b/backend/visitran/events/base_types.py @@ -100,6 +100,18 @@ def level_tag(self) -> EventLevel: def audience(self) -> str: return "user" + def title(self) -> str: + """Clean, short title for the activity feed.""" + return self.message() + + def subtitle(self) -> str: + """Contextual metadata shown below the title.""" + return "" + + def status(self) -> str: + """One of: running, success, error, warning, info.""" + return "success" + class NoFile: """Prevents an event from going to the file.""" diff --git a/backend/visitran/events/eventmgr.py b/backend/visitran/events/eventmgr.py index b6b079f..09b41dc 100644 --- a/backend/visitran/events/eventmgr.py +++ b/backend/visitran/events/eventmgr.py @@ -126,11 +126,26 @@ def create_line(self, msg: EventMsg) -> str: def write_line(self, msg: EventMsg) -> None: line = self.create_line(msg) if self._python_logger is not None and self.name == "file_log": - # send_to_logger(self._python_logger, msg.info.level, line) #send logs to file - # Using shared memory to write the logs - # LogHelper.publish(LogHelper.log(line, msg.info.level)) audience = getattr(msg.data, "audience", lambda: "developer")() - LogHelper.publish_log(StateStore.get("log_events_id"), LogHelper.log(line, msg.info.level, audience)) + if audience == "user": + ts = datetime.utcnow().strftime("%H:%M:%S") + title = getattr(msg.data, "title", lambda: msg.info.msg)() + subtitle = getattr(msg.data, "subtitle", lambda: "")() + event_status = getattr(msg.data, "status", lambda: "info")() + event_code = getattr(msg.data, "code", lambda: "")() + payload = { + "level": msg.info.level, + "audience": audience, + "title": title, + "subtitle": subtitle, + "status": event_status, + "code": event_code, + "timestamp": ts, + "message": msg.info.msg, + } + LogHelper.publish_log(StateStore.get("log_events_id"), payload) + else: + LogHelper.publish_log(StateStore.get("log_events_id"), LogHelper.log(line, msg.info.level, audience)) if self._stream is not None and self.name == "stdout_log": self._stream.write(line + "\n") @@ -165,7 +180,7 @@ def create_debug_line(self, msg: EventMsg) -> str: color_ts = self._get_color_tag(msg=msg, invoker="ts") color_msg = self._get_color_tag(msg=msg, invoker="msg") color_level = self._get_color_tag(msg=msg, invoker="level") - log_line += f"{color_ts}{ts} {color_level}[{level:<5}]{color_msg}{self._get_thread_name()} {msg.info.msg}" + log_line += f"{color_ts}{ts} {color_level}[{level:<5}]{color_msg} {msg.info.msg}" return log_line def _get_color_tag(self, msg: EventMsg, invoker: str) -> Any: diff --git a/backend/visitran/events/types.py b/backend/visitran/events/types.py index e90c061..eb8d2d5 100644 --- a/backend/visitran/events/types.py +++ b/backend/visitran/events/types.py @@ -835,6 +835,20 @@ def message(self) -> str: mat = f" as {self.materialization}" if self.materialization else "" return f'Building model "{self.model_name}" → {self.destination_table}{mat} from "{self.source_table}"' + def title(self) -> str: + return f'Building model "{self.model_name}"' + + def subtitle(self) -> str: + mat = self.materialization.upper() if self.materialization else "" + parts = [] + if mat: + parts.append(mat) + parts.append(f"{self.source_table} → {self.destination_table}") + return " · ".join(parts) + + def status(self) -> str: + return "running" + @dataclass class ModelRunSucceeded(UserLevel, proto_type.ModelRunSucceeded): @@ -845,6 +859,12 @@ def message(self) -> str: dur = f" in {self.duration_seconds:.1f}s" if self.duration_seconds else "" return f'Model "{self.model_name}" built successfully{dur}' + def title(self) -> str: + return f'Model "{self.model_name}" built successfully' + + def subtitle(self) -> str: + return f"{self.duration_seconds:.1f}s elapsed" if self.duration_seconds else "" + @dataclass class ModelRunFailed(UserLevel, proto_type.ModelRunFailed): @@ -855,6 +875,18 @@ def message(self) -> str: short_err = (self.error[:120] + "…") if len(self.error) > 120 else self.error return f'Model "{self.model_name}" failed: {short_err}' + def title(self) -> str: + return f'Model "{self.model_name}" failed' + + def subtitle(self) -> str: + return (self.error[:150] + "…") if len(self.error) > 150 else self.error + + def status(self) -> str: + return "error" + + def level_tag(self) -> EventLevel: + return EventLevel.ERROR + @dataclass class TransformationApplied(UserLevel, proto_type.TransformationApplied): @@ -864,6 +896,9 @@ def code(self) -> str: def message(self) -> str: return f'Applied {self.transformation_type} transformation on "{self.model_name}"' + def title(self) -> str: + return f'Applied {self.transformation_type} transformation on "{self.model_name}"' + @dataclass class TransformationDeleted(UserLevel, proto_type.TransformationDeleted): @@ -873,6 +908,9 @@ def code(self) -> str: def message(self) -> str: return f'Removed {self.transformation_type} transformation from "{self.model_name}"' + def title(self) -> str: + return f'Removed {self.transformation_type} transformation from "{self.model_name}"' + @dataclass class ModelConfigured(UserLevel, proto_type.ModelConfigured): @@ -882,6 +920,12 @@ def code(self) -> str: def message(self) -> str: return f'Configured "{self.model_name}" — source: {self.source}, destination: {self.destination}' + def title(self) -> str: + return f'Configured model "{self.model_name}"' + + def subtitle(self) -> str: + return f"{self.source} → {self.destination}" + @dataclass class SeedCompleted(UserLevel, proto_type.SeedCompleted): @@ -893,6 +937,17 @@ def message(self) -> str: return f'Seed "{self.seed_name}" loaded into "{self.schema_name}"' return f'Seed "{self.seed_name}" failed in "{self.schema_name}"' + def title(self) -> str: + if self.status == "Success": + return f'Seed "{self.seed_name}" loaded successfully' + return f'Seed "{self.seed_name}" failed' + + def subtitle(self) -> str: + return f"Schema: {self.schema_name}" + + def status(self) -> str: + return "success" if self.status == "Success" else "error" + @dataclass class JobCreated(UserLevel, proto_type.JobCreated): @@ -902,6 +957,12 @@ def code(self) -> str: def message(self) -> str: return f'Job "{self.job_name}" created for environment "{self.environment_name}"' + def title(self) -> str: + return f'Job "{self.job_name}" created' + + def subtitle(self) -> str: + return f"Environment: {self.environment_name}" + @dataclass class JobUpdated(UserLevel, proto_type.JobUpdated): @@ -911,6 +972,9 @@ def code(self) -> str: def message(self) -> str: return f'Job "{self.job_name}" updated' + def title(self) -> str: + return f'Job "{self.job_name}" updated' + @dataclass class JobDeleted(UserLevel, proto_type.JobDeleted): @@ -920,6 +984,12 @@ def code(self) -> str: def message(self) -> str: return f'Job "{self.job_name}" deleted' + def title(self) -> str: + return f'Job "{self.job_name}" deleted' + + def status(self) -> str: + return "warning" + @dataclass class JobTriggered(UserLevel, proto_type.JobTriggered): @@ -930,6 +1000,16 @@ def message(self) -> str: scope_desc = "all models" if self.scope == "job" else f"model {self.scope}" return f'Job "{self.job_name}" triggered manually — running {scope_desc}' + def title(self) -> str: + return f'Job "{self.job_name}" triggered' + + def subtitle(self) -> str: + scope_desc = "All models" if self.scope == "job" else f"Model: {self.scope}" + return f"{scope_desc} · Manual trigger" + + def status(self) -> str: + return "running" + # ─── P3: Model/project CRUD ────────────────────────────────────────── @@ -941,6 +1021,9 @@ def code(self) -> str: def message(self) -> str: return f'Model "{self.model_name}" created' + def title(self) -> str: + return f'Model "{self.model_name}" created' + @dataclass class FileDeleted(UserLevel, proto_type.FileDeleted): @@ -950,6 +1033,12 @@ def code(self) -> str: def message(self) -> str: return f'Deleted: {self.file_names}' + def title(self) -> str: + return f'Deleted: {self.file_names}' + + def status(self) -> str: + return "warning" + @dataclass class FileRenamed(UserLevel, proto_type.FileRenamed): @@ -959,6 +1048,12 @@ def code(self) -> str: def message(self) -> str: return f'Renamed "{self.old_name}" → "{self.new_name}"' + def title(self) -> str: + return f'File renamed' + + def subtitle(self) -> str: + return f'"{self.old_name}" → "{self.new_name}"' + # ─── P4: Connection & environment ──────────────────────────────────── @@ -970,6 +1065,12 @@ def code(self) -> str: def message(self) -> str: return f'Connection created ({self.datasource})' + def title(self) -> str: + return "Connection created" + + def subtitle(self) -> str: + return f"Datasource: {self.datasource}" + @dataclass class ConnectionTested(UserLevel, proto_type.ConnectionTested): @@ -979,6 +1080,13 @@ def code(self) -> str: def message(self) -> str: return f'Connection test: {self.result} ({self.datasource})' + def title(self) -> str: + status = "passed" if self.result == "success" else self.result + return f"Connection test {status}" + + def subtitle(self) -> str: + return f"Datasource: {self.datasource}" + @dataclass class ConnectionDeletedEvt(UserLevel, proto_type.ConnectionDeletedEvt): @@ -988,6 +1096,12 @@ def code(self) -> str: def message(self) -> str: return f'Connection "{self.connection_name}" deleted' + def title(self) -> str: + return f'Connection "{self.connection_name}" deleted' + + def status(self) -> str: + return "warning" + @dataclass class ConnectionDeleteFailedEvt(UserLevel, proto_type.ConnectionDeleteFailed): @@ -1001,6 +1115,15 @@ def message(self) -> str: short = (self.reason[:120] + "…") if len(self.reason) > 120 else self.reason return f'Failed to delete connection "{self.connection_name}": {short}' + def title(self) -> str: + return f'Failed to delete connection "{self.connection_name}"' + + def subtitle(self) -> str: + return (self.reason[:150] + "…") if len(self.reason) > 150 else self.reason + + def status(self) -> str: + return "error" + @dataclass class EnvironmentCreated(UserLevel, proto_type.EnvironmentCreated): @@ -1010,6 +1133,9 @@ def code(self) -> str: def message(self) -> str: return f'Environment "{self.environment_name}" created' + def title(self) -> str: + return f'Environment "{self.environment_name}" created' + @dataclass class EnvironmentDeleted(UserLevel, proto_type.EnvironmentDeleted): @@ -1018,3 +1144,9 @@ def code(self) -> str: def message(self) -> str: return f'Environment "{self.environment_name}" deleted' + + def title(self) -> str: + return f'Environment "{self.environment_name}" deleted' + + def status(self) -> str: + return "warning" 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 68b9681..061c519 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 @@ -739,18 +739,75 @@ function NoCodeModel({ nodeData }) {
- {el.message} + + {el.status === "error" + ? "✗" + : el.status === "running" + ? "●" + : el.status === "warning" + ? "⚠" + : "✓"} + +
+
+ {el.title || el.message} +
+ {el.subtitle && ( +
+ {el.subtitle} +
+ )} +
+ {el.timestamp && ( + + {el.timestamp} + + )}
) : (
{ - const message = data?.data?.message; - const level = (data?.data?.level || "info").toLowerCase(); - const audience = data?.data?.audience || "developer"; - if (!message) return; + const d = data?.data || {}; + const message = d.message; + const level = (d.level || "info").toLowerCase(); + const audience = d.audience || "developer"; + if (!message && !d.title) return; const doc = document.getElementsByClassName("logsSection"); if (doc[0]) { setTimeout(() => { doc[0].scrollTop = doc[0].scrollHeight; }, 800); } - setLogsInfo((old) => [...old, { level, message, audience }]); + if (audience === "user") { + setLogsInfo((old) => [ + ...old, + { + level, + audience, + title: d.title || message, + subtitle: d.subtitle || "", + status: d.status || "info", + timestamp: d.timestamp || "", + message: message || d.title, + }, + ]); + } else { + setLogsInfo((old) => [...old, { level, message, audience }]); + } }); }); }); From 03766bb2a0b31272eb853cd1fd03714aeb7a260f Mon Sep 17 00:00:00 2001 From: abhizipstack Date: Fri, 17 Apr 2026 17:08:20 +0530 Subject: [PATCH 18/20] fix: preserve specific exception status codes in connection delete Re-raise VisitranBackendBaseException subclasses (ConnectionNotExists 404, ConnectionDependencyError 409) directly instead of wrapping all errors as ConnectionDeleteFailed 400. Co-Authored-By: Claude Opus 4.6 (1M context) --- backend/backend/core/routers/connection/views.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/backend/backend/core/routers/connection/views.py b/backend/backend/core/routers/connection/views.py index 5af16e3..296abb7 100644 --- a/backend/backend/core/routers/connection/views.py +++ b/backend/backend/core/routers/connection/views.py @@ -121,6 +121,7 @@ def connection_usage(request: Request, connection_id: str) -> Response: @handle_permission def delete_connection(request: Request, connection_id: str) -> Response: from backend.errors.validation_exceptions import ConnectionDeleteFailed + from backend.errors.visitran_backend_base_exceptions import VisitranBackendBaseException con_context = ConnectionContext() conn_name = connection_id @@ -128,6 +129,8 @@ def delete_connection(request: Request, connection_id: str) -> Response: conn_data = con_context.get_connection(connection_id=connection_id) conn_name = conn_data.get("name", connection_id) if conn_data else connection_id con_context.delete_connection(connection_id=connection_id) + except VisitranBackendBaseException: + raise except Exception as e: fire_event(ConnectionDeleteFailedEvt(connection_name=conn_name, reason=str(e))) raise ConnectionDeleteFailed( From 96ad05cd547b57ce9b45e3d01fc5ba820982116e Mon Sep 17 00:00:00 2001 From: abhizipstack Date: Fri, 17 Apr 2026 17:13:32 +0530 Subject: [PATCH 19/20] fix: reset pagination to page 1 on job switch in run history Explicitly pass page 1 when switching jobs to prevent fetching a stale page number from the previous job selection. Co-Authored-By: Claude Opus 4.6 (1M context) --- frontend/src/ide/run-history/Runhistory.jsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/frontend/src/ide/run-history/Runhistory.jsx b/frontend/src/ide/run-history/Runhistory.jsx index 2bd50b0..cec9cc6 100644 --- a/frontend/src/ide/run-history/Runhistory.jsx +++ b/frontend/src/ide/run-history/Runhistory.jsx @@ -171,7 +171,7 @@ const Runhistory = () => { : null; const initial = matchedFromUrl?.value ?? jobIds[0].value; setFilterQuery((prev) => ({ ...prev, job: initial })); - getRunHistoryList(initial); + getRunHistoryList(initial, 1, pageSize); } } catch (error) { console.error("Failed to load jobs", error); @@ -220,7 +220,8 @@ const Runhistory = () => { const handleJobChange = useCallback( (value) => { setFilterQuery({ status: "", job: value, trigger: "", scope: "" }); - getRunHistoryList(value); + setCurrentPage(1); + getRunHistoryList(value, 1, pageSize); setSearchParams( (prev) => { const next = new URLSearchParams(prev); From fe95b488e76ca2f62985e8b278dad4773efa102f Mon Sep 17 00:00:00 2001 From: abhizipstack Date: Fri, 17 Apr 2026 17:41:04 +0530 Subject: [PATCH 20/20] fix: rename status() to event_status() to avoid proto field shadowing SeedCompleted has a proto field named 'status' which shadows the method. Renamed to event_status() across all UserLevel events and the base class to prevent runtime errors. Co-Authored-By: Claude Opus 4.6 (1M context) --- backend/visitran/events/base_types.py | 2 +- backend/visitran/events/eventmgr.py | 2 +- backend/visitran/events/types.py | 18 +++++++++--------- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/backend/visitran/events/base_types.py b/backend/visitran/events/base_types.py index 1cbca4d..dff2c96 100644 --- a/backend/visitran/events/base_types.py +++ b/backend/visitran/events/base_types.py @@ -108,7 +108,7 @@ def subtitle(self) -> str: """Contextual metadata shown below the title.""" return "" - def status(self) -> str: + def event_status(self) -> str: """One of: running, success, error, warning, info.""" return "success" diff --git a/backend/visitran/events/eventmgr.py b/backend/visitran/events/eventmgr.py index 09b41dc..3b73fc8 100644 --- a/backend/visitran/events/eventmgr.py +++ b/backend/visitran/events/eventmgr.py @@ -131,7 +131,7 @@ def write_line(self, msg: EventMsg) -> None: ts = datetime.utcnow().strftime("%H:%M:%S") title = getattr(msg.data, "title", lambda: msg.info.msg)() subtitle = getattr(msg.data, "subtitle", lambda: "")() - event_status = getattr(msg.data, "status", lambda: "info")() + event_status = getattr(msg.data, "event_status", lambda: "info")() event_code = getattr(msg.data, "code", lambda: "")() payload = { "level": msg.info.level, diff --git a/backend/visitran/events/types.py b/backend/visitran/events/types.py index eb8d2d5..edabc96 100644 --- a/backend/visitran/events/types.py +++ b/backend/visitran/events/types.py @@ -846,7 +846,7 @@ def subtitle(self) -> str: parts.append(f"{self.source_table} → {self.destination_table}") return " · ".join(parts) - def status(self) -> str: + def event_status(self) -> str: return "running" @@ -881,7 +881,7 @@ def title(self) -> str: def subtitle(self) -> str: return (self.error[:150] + "…") if len(self.error) > 150 else self.error - def status(self) -> str: + def event_status(self) -> str: return "error" def level_tag(self) -> EventLevel: @@ -945,7 +945,7 @@ def title(self) -> str: def subtitle(self) -> str: return f"Schema: {self.schema_name}" - def status(self) -> str: + def event_status(self) -> str: return "success" if self.status == "Success" else "error" @@ -987,7 +987,7 @@ def message(self) -> str: def title(self) -> str: return f'Job "{self.job_name}" deleted' - def status(self) -> str: + def event_status(self) -> str: return "warning" @@ -1007,7 +1007,7 @@ def subtitle(self) -> str: scope_desc = "All models" if self.scope == "job" else f"Model: {self.scope}" return f"{scope_desc} · Manual trigger" - def status(self) -> str: + def event_status(self) -> str: return "running" @@ -1036,7 +1036,7 @@ def message(self) -> str: def title(self) -> str: return f'Deleted: {self.file_names}' - def status(self) -> str: + def event_status(self) -> str: return "warning" @@ -1099,7 +1099,7 @@ def message(self) -> str: def title(self) -> str: return f'Connection "{self.connection_name}" deleted' - def status(self) -> str: + def event_status(self) -> str: return "warning" @@ -1121,7 +1121,7 @@ def title(self) -> str: def subtitle(self) -> str: return (self.reason[:150] + "…") if len(self.reason) > 150 else self.reason - def status(self) -> str: + def event_status(self) -> str: return "error" @@ -1148,5 +1148,5 @@ def message(self) -> str: def title(self) -> str: return f'Environment "{self.environment_name}" deleted' - def status(self) -> str: + def event_status(self) -> str: return "warning"