From 49e60dad7b69e37632e04fdbce8fd17ea49199b1 Mon Sep 17 00:00:00 2001 From: abhizipstack Date: Wed, 15 Apr 2026 12:38:18 +0530 Subject: [PATCH 01/14] feat: add Quick Deploy button to model tab (OR-1456) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Quick Deploy runs the current model through the same pipeline as the scheduler's "Run Now" — reusing an existing enabled job's environment, model_configs (materialization, incremental, watermark), retries, Slack notifications, and TaskRunHistory — but scopes the DAG run to just the current model. Backend - trigger_scheduled_run accepts an optional models_override list. When provided it's passed to execute_visitran_run_command as current_models so only those models execute. Recorded in TaskRunHistory.kwargs with source="quick_deploy" so ad-hoc runs are distinguishable from scheduled ones. Models_override is threaded through the retry path. - New endpoint POST /jobs/trigger-periodic-task//model/ dispatches a single-model run; validates the model is present and enabled on the job before running. - New endpoint GET /jobs/quick-deploy/candidates/ returns the jobs in the project that include the model with enabled=true. - trigger_task_once and the new single-model variant share a _dispatch_task_run helper (Celery-first with sync fallback). Frontend - Primary "Quick Deploy" button in the model tab action row. - Click → fetches candidates: - 0 → modal explains no job covers this model and links to the scheduler page to create one. - 1 → confirm modal shows job + environment before running. - 2+ → picker modal with a radio list of qualifying jobs. - On success: success notification with job + environment summary, triggers explorer refresh so the status badge reflects the new run. - On failure: existing error notification path. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../backend/core/scheduler/celery_tasks.py | 51 ++++- backend/backend/core/scheduler/urls.py | 12 + backend/backend/core/scheduler/views.py | 106 +++++++-- .../editor/no-code-model/no-code-model.jsx | 209 ++++++++++++++++++ frontend/src/ide/scheduler/service.js | 18 ++ 5 files changed, 364 insertions(+), 32 deletions(-) diff --git a/backend/backend/core/scheduler/celery_tasks.py b/backend/backend/core/scheduler/celery_tasks.py index 65c77147..bff7b38a 100644 --- a/backend/backend/core/scheduler/celery_tasks.py +++ b/backend/backend/core/scheduler/celery_tasks.py @@ -180,10 +180,23 @@ def _trigger_chained_job(user_task: UserTaskDetails, user_id: int, organization_ acks_late=True, max_retries=0, # we handle retries ourselves ) -def trigger_scheduled_run(self, *, user_task_id: int, user_id: int, organization_id: str = None): +def trigger_scheduled_run( + self, + *, + user_task_id: int, + user_id: int, + organization_id: str = None, + models_override: list = None, +): """Execute a scheduled Visitran run. This is the Celery task wired to ``Task.SCHEDULER_JOB``. + + Args: + models_override: If provided, execute only these model names (plus + their downstream dependents) instead of every model in + ``user_task.model_configs``. Used by the Quick Deploy flow to + run a single model against the job's environment. """ from backend.application.context.application import ApplicationContext from backend.utils.tenant_context import _get_tenant_context @@ -228,17 +241,22 @@ def trigger_scheduled_run(self, *, user_task_id: int, user_id: int, organization # ── Create run-history entry ────────────────────────────────────── # Note: organization is automatically set by DefaultOrganizationMixin from tenant context + run_kwargs = { + "user_task_id": user_task_id, + "user_id": user_id, + "model_configs": user_task.model_configs, + } + if models_override: + run_kwargs["models_override"] = list(models_override) + run_kwargs["source"] = "quick_deploy" + run = TaskRunHistory.objects.create( task_id=self.request.id or f"manual-{user_task_id}-{uuid.uuid4().hex[:8]}", retry_num=retry_num, status="STARTED", start_time=timezone.now(), user_task_detail=user_task, - kwargs={ - "user_task_id": user_task_id, - "user_id": user_id, - "model_configs": user_task.model_configs, - }, + kwargs=run_kwargs, ) # ── Mark task as running ────────────────────────────────────────── @@ -288,7 +306,13 @@ def trigger_scheduled_run(self, *, user_task_id: int, user_id: int, organization timeout = user_task.run_timeout_seconds or 0 with _timeout_guard(timeout): - app_context.execute_visitran_run_command(environment_id=environment_id) + if models_override: + app_context.execute_visitran_run_command( + environment_id=environment_id, + current_models=list(models_override), + ) + else: + app_context.execute_visitran_run_command(environment_id=environment_id) # ── Mark success ────────────────────────────────────────────── success = True @@ -325,12 +349,15 @@ def trigger_scheduled_run(self, *, user_task_id: int, user_id: int, organization retry_num + 1, user_task.max_retries, ) + retry_kwargs = { + "user_task_id": user_task_id, + "user_id": user_id, + "organization_id": organization_id, + } + if models_override: + retry_kwargs["models_override"] = list(models_override) trigger_scheduled_run.apply_async( - kwargs={ - "user_task_id": user_task_id, - "user_id": user_id, - "organization_id": organization_id, - }, + kwargs=retry_kwargs, countdown=30 * (retry_num + 1), # progressive backoff ) return diff --git a/backend/backend/core/scheduler/urls.py b/backend/backend/core/scheduler/urls.py index c9f05f68..f9838590 100644 --- a/backend/backend/core/scheduler/urls.py +++ b/backend/backend/core/scheduler/urls.py @@ -8,6 +8,8 @@ update_periodic_task, task_run_history, trigger_task_once, + trigger_task_once_for_model, + list_deploy_candidates, get_periodic_task, get_model_columns, ) @@ -32,6 +34,16 @@ trigger_task_once, name="trigger_task_once", ), + path( + "/trigger-periodic-task//model/", + trigger_task_once_for_model, + name="trigger_task_once_for_model", + ), + path( + "/quick-deploy/candidates/", + list_deploy_candidates, + name="list_deploy_candidates", + ), # Model columns endpoint for incremental job configuration path( "/model//columns", diff --git a/backend/backend/core/scheduler/views.py b/backend/backend/core/scheduler/views.py index 77fa8da5..04d395db 100644 --- a/backend/backend/core/scheduler/views.py +++ b/backend/backend/core/scheduler/views.py @@ -605,30 +605,16 @@ def task_run_history(request, project_id, user_task_id): ) -@api_view(["POST"]) -@permission_classes([IsAuthenticated]) -def trigger_task_once(request, project_id, user_task_id): - """Trigger a task to run immediately. - - Tries Celery first; if the broker is unreachable, falls back to - synchronous (in-process) execution so local dev works without Redis. - """ - try: - task = UserTaskDetails.objects.get( - id=user_task_id, project__project_uuid=project_id - ) - except UserTaskDetails.DoesNotExist: - return Response( - {"error": "Task not found"}, status=status.HTTP_404_NOT_FOUND - ) - +def _dispatch_task_run(task, user_id, models_override=None): + """Shared dispatch: try Celery broker, fall back to synchronous execution.""" run_kwargs = { "user_task_id": task.id, - "user_id": request.user.id, + "user_id": user_id, "organization_id": str(task.organization_id) if task.organization_id else None, } + if models_override: + run_kwargs["models_override"] = list(models_override) - # Try async dispatch via Celery broker try: from backend.core.scheduler.task_constant import Task as TaskConst from celery import current_app @@ -646,7 +632,6 @@ def trigger_task_once(request, project_id, user_task_id): except Exception as broker_err: logger.warning("Celery broker unavailable (%s), running synchronously.", broker_err) - # Fallback: run synchronously in-process try: from backend.core.scheduler.celery_tasks import trigger_scheduled_run @@ -661,3 +646,84 @@ def trigger_task_once(request, project_id, user_task_id): return Response( {"error": str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR ) + + +@api_view(["POST"]) +@permission_classes([IsAuthenticated]) +def trigger_task_once(request, project_id, user_task_id): + """Trigger a task to run immediately. + + Tries Celery first; if the broker is unreachable, falls back to + synchronous (in-process) execution so local dev works without Redis. + """ + try: + task = UserTaskDetails.objects.get( + id=user_task_id, project__project_uuid=project_id + ) + except UserTaskDetails.DoesNotExist: + return Response( + {"error": "Task not found"}, status=status.HTTP_404_NOT_FOUND + ) + + return _dispatch_task_run(task, request.user.id) + + +@api_view(["POST"]) +@permission_classes([IsAuthenticated]) +def trigger_task_once_for_model(request, project_id, user_task_id, model_name): + """Quick Deploy: trigger a job to run a single model against its configured environment. + + Execution reuses the scheduler pipeline (TaskRunHistory, retries, Slack + notifications) but scopes the DAG run to ``model_name`` only. The model + must be present and enabled in the task's ``model_configs``. + """ + try: + task = UserTaskDetails.objects.select_related("project").get( + id=user_task_id, project__project_uuid=project_id + ) + except UserTaskDetails.DoesNotExist: + return Response( + {"error": "Task not found"}, status=status.HTTP_404_NOT_FOUND + ) + + model_cfg = (task.model_configs or {}).get(model_name) + if not model_cfg or not model_cfg.get("enabled", True): + return Response( + {"error": f"Model '{model_name}' is not enabled on this job."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + return _dispatch_task_run(task, request.user.id, models_override=[model_name]) + + +@api_view(["GET"]) +@permission_classes([IsAuthenticated]) +def list_deploy_candidates(request, project_id, model_name): + """Return jobs in ``project_id`` that can deploy ``model_name``. + + A job qualifies when ``model_name`` is a key in ``model_configs`` and its + ``enabled`` flag is truthy (defaults to True if the flag is absent). + """ + tasks = UserTaskDetails.objects.select_related("environment", "project").filter( + project__project_uuid=project_id, + ) + + candidates = [] + for task in tasks: + cfg = (task.model_configs or {}).get(model_name) + if not cfg: + continue + if not cfg.get("enabled", True): + continue + candidates.append({ + "user_task_id": task.id, + "task_name": task.task_name, + "environment_id": str(task.environment.environment_id), + "environment_name": getattr(task.environment, "name", ""), + "status": task.status, + "prev_run_status": task.prev_run_status, + "task_run_time": task.task_run_time.isoformat() if task.task_run_time else None, + "next_run_time": task.next_run_time.isoformat() if task.next_run_time else None, + }) + + return Response({"data": candidates}, status=status.HTTP_200_OK) 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 969a91ae..1c7b627f 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 @@ -4,6 +4,7 @@ import { Input, Modal, Pagination, + Radio, Select, Space, Table, @@ -12,6 +13,7 @@ import { Tooltip, Typography, } from "antd"; +import { useNavigate } from "react-router-dom"; import { useEffect, useMemo, useRef, useState } from "react"; import { Resizable } from "react-resizable"; import Cookies from "js-cookie"; @@ -42,6 +44,7 @@ import { LineHeightOutlined, LinkOutlined, MergeCellsOutlined, + PlayCircleOutlined, PlusSquareOutlined, ProfileOutlined, RetweetOutlined, @@ -98,6 +101,7 @@ import { CopyableCell } from "../../../widgets/copyable-cell/index.js"; import "./no-code-model.css"; import "reactflow/dist/style.css"; import { useNotificationService } from "../../../service/notification-service.js"; +import { useJobService } from "../../scheduler/service.js"; import { useTransformIdStore } from "../../../store/transform-id-store.js"; import { getCombineColumnsSpec, @@ -241,6 +245,16 @@ function NoCodeModel({ nodeData }) { const { renamedModel, setRenamedModel } = useProjectStore(); const { setPendingLineageTab } = useLineageTabStore(); const axiosPrivate = useAxiosPrivate(); + const navigate = useNavigate(); + const { listDeployCandidates, runTaskForModel } = useJobService(); + + const [quickDeployModal, setQuickDeployModal] = useState({ + open: false, + step: "loading", // loading | empty | confirm | pick + candidates: [], + selectedTaskId: null, + submitting: false, + }); const modelName = nodeData?.node?.title || @@ -1631,6 +1645,88 @@ function NoCodeModel({ nodeData }) { setSpecRevert(true); }; + const openQuickDeploy = async () => { + const currentModelName = nodeData?.node?.title; + if (!currentModelName) return; + setQuickDeployModal({ + open: true, + step: "loading", + candidates: [], + selectedTaskId: null, + submitting: false, + }); + try { + const candidates = await listDeployCandidates( + projectId, + currentModelName + ); + if (!candidates.length) { + setQuickDeployModal((prev) => ({ + ...prev, + step: "empty", + candidates: [], + })); + } else if (candidates.length === 1) { + setQuickDeployModal((prev) => ({ + ...prev, + step: "confirm", + candidates, + selectedTaskId: candidates[0].user_task_id, + })); + } else { + setQuickDeployModal((prev) => ({ + ...prev, + step: "pick", + candidates, + selectedTaskId: candidates[0].user_task_id, + })); + } + } catch (error) { + setQuickDeployModal((prev) => ({ ...prev, open: false })); + notify({ error }); + } + }; + + const closeQuickDeploy = () => { + setQuickDeployModal((prev) => ({ ...prev, open: false })); + }; + + const confirmQuickDeploy = async () => { + const currentModelName = nodeData?.node?.title; + const { selectedTaskId } = quickDeployModal; + if (!currentModelName || !selectedTaskId) return; + setQuickDeployModal((prev) => ({ ...prev, submitting: true })); + try { + await runTaskForModel(projectId, selectedTaskId, currentModelName); + const selected = quickDeployModal.candidates.find( + (c) => c.user_task_id === selectedTaskId + ); + notify({ + type: "success", + message: "Quick Deploy Triggered", + description: `"${currentModelName}" is running on "${ + selected?.environment_name || "the selected environment" + }" via job "${ + selected?.task_name || "" + }". Check Run History for progress.`, + }); + setRefreshModels(true); + setQuickDeployModal((prev) => ({ + ...prev, + open: false, + submitting: false, + })); + } catch (error) { + setQuickDeployModal((prev) => ({ ...prev, submitting: false })); + notify({ error }); + } + }; + + const goToScheduler = () => { + setQuickDeployModal((prev) => ({ ...prev, open: false })); + navigate("/project/job/list"); + }; + const runTransformation = (spec) => { setIsLoading(true); const specYaml = yaml.dump(removeUnwantedKeys(spec)); @@ -2586,6 +2682,20 @@ function NoCodeModel({ nodeData }) { />
+
)} + + + + + ) : quickDeployModal.step === "loading" ? null : ( + + + + + ) + } + > + {quickDeployModal.step === "loading" && ( +
+ +
+ )} + {quickDeployModal.step === "empty" && ( + + No job includes model {nodeData?.node?.title} yet. + Create a scheduled job for this model first; then you can Quick + Deploy against its configured environment. + + )} + {quickDeployModal.step === "confirm" && + quickDeployModal.candidates[0] && ( +
+ + This will run {nodeData?.node?.title} against + environment{" "} + + {quickDeployModal.candidates[0].environment_name || + "(unnamed)"} + {" "} + using job{" "} + {quickDeployModal.candidates[0].task_name}. + + + The run is tracked in Run History alongside scheduled runs. + +
+ )} + {quickDeployModal.step === "pick" && ( +
+ + Multiple jobs deploy {nodeData?.node?.title}. + Pick one: + + + setQuickDeployModal((prev) => ({ + ...prev, + selectedTaskId: e.target.value, + })) + } + style={{ display: "flex", flexDirection: "column", gap: 8 }} + > + {quickDeployModal.candidates.map((c) => ( + + {c.task_name} + + env: {c.environment_name || "(unnamed)"} + + + ))} + +
+ )} +
); } diff --git a/frontend/src/ide/scheduler/service.js b/frontend/src/ide/scheduler/service.js index b6b3bb3b..9d36728d 100644 --- a/frontend/src/ide/scheduler/service.js +++ b/frontend/src/ide/scheduler/service.js @@ -63,6 +63,22 @@ export function useJobService() { return response.data; }; + const runTaskForModel = async (projId, taskId, modelName) => { + const url = `${jobsUrl( + projId + )}/trigger-periodic-task/${taskId}/model/${encodeURIComponent(modelName)}`; + const response = await axiosPrivate.post(url, {}, { headers }); + return response.data; + }; + + const listDeployCandidates = async (projId, modelName) => { + const url = `${jobsUrl( + projId + )}/quick-deploy/candidates/${encodeURIComponent(modelName)}`; + const response = await axiosPrivate.get(url); + return response.data?.data || []; + }; + const getProjects = async () => { const url = `/api/v1/visitran/${orgId}/projects`; const response = await axiosPrivate.get(url); @@ -117,6 +133,8 @@ export function useJobService() { updateTask, deleteTask, runTask, + runTaskForModel, + listDeployCandidates, getProjects, getEnvironments, getProjectModels, From ff80812b879bded15408a385d6661b00d1d0d40b Mon Sep 17 00:00:00 2001 From: abhizipstack Date: Wed, 15 Apr 2026 14:12:43 +0530 Subject: [PATCH 02/14] feat: identify quick-deploy runs in Run History + fix env name - Run History now shows a Source column with a blue "Quick Deploy" tag and the overridden model name(s) for ad-hoc runs; scheduled runs show a muted "Scheduled" label. Reads the markers already written into TaskRunHistory.kwargs ("source" + "models_override"). - list_deploy_candidates was returning environment_name="" because it read task.environment.name; the field on EnvironmentModels is environment_name. Fixed to prefer environment_name with a fallback to name for defensive cases. Co-Authored-By: Claude Opus 4.6 (1M context) --- backend/backend/core/scheduler/views.py | 4 +++- frontend/src/ide/run-history/Runhistory.jsx | 24 +++++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/backend/backend/core/scheduler/views.py b/backend/backend/core/scheduler/views.py index 04d395db..ccd34753 100644 --- a/backend/backend/core/scheduler/views.py +++ b/backend/backend/core/scheduler/views.py @@ -719,7 +719,9 @@ def list_deploy_candidates(request, project_id, model_name): "user_task_id": task.id, "task_name": task.task_name, "environment_id": str(task.environment.environment_id), - "environment_name": getattr(task.environment, "name", ""), + "environment_name": getattr( + task.environment, "environment_name", "" + ) or getattr(task.environment, "name", ""), "status": task.status, "prev_run_status": task.prev_run_status, "task_run_time": task.task_run_time.isoformat() if task.task_run_time else None, diff --git a/frontend/src/ide/run-history/Runhistory.jsx b/frontend/src/ide/run-history/Runhistory.jsx index b4738067..0ae995aa 100644 --- a/frontend/src/ide/run-history/Runhistory.jsx +++ b/frontend/src/ide/run-history/Runhistory.jsx @@ -216,6 +216,30 @@ const Runhistory = () => { ), }, + { + title: "Source", + key: "source", + width: 200, + render: (_, record) => { + const isQuickDeploy = record.kwargs?.source === "quick_deploy"; + if (!isQuickDeploy) { + return ( + Scheduled + ); + } + const models = record.kwargs?.models_override || []; + return ( + + Quick Deploy + {models.length > 0 && ( + + {models.join(", ")} + + )} + + ); + }, + }, { title: "Triggered", dataIndex: "start_time", From 354fada270101631b8d79e260755b7889fe511eb Mon Sep 17 00:00:00 2001 From: abhizipstack Date: Wed, 15 Apr 2026 14:25:13 +0530 Subject: [PATCH 03/14] feat: show recent runs popup on Quick Deploy dropdown (OR-1456) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extend the Quick Deploy control into a Space.Compact pair: the primary button still opens the deploy flow; the adjacent chevron opens a small popup listing recent runs for the current model, mixing scheduled and quick-deploy runs. A "View full Run History" link-button at the bottom navigates to /project/job/history. Backend - GET /jobs/quick-deploy/recent-runs/?limit=5 returns the most recent TaskRunHistory entries where the job's model_configs includes the model. Each row exposes status, start_time, environment_name, task_name, error_message, and source (derived from kwargs.source, defaulting to "scheduled"). Frontend - useJobService gets listRecentRunsForModel(projId, modelName, limit). - Dropdown fetches on open (once per model name — cached within component state). Panel shows colored status tag, a Quick Deploy / Scheduled source tag, job name, environment, and a relative time. Co-Authored-By: Claude Opus 4.6 (1M context) --- backend/backend/core/scheduler/urls.py | 6 + backend/backend/core/scheduler/views.py | 43 +++++ .../editor/no-code-model/no-code-model.jsx | 163 ++++++++++++++++-- frontend/src/ide/scheduler/service.js | 9 + 4 files changed, 206 insertions(+), 15 deletions(-) diff --git a/backend/backend/core/scheduler/urls.py b/backend/backend/core/scheduler/urls.py index f9838590..c9e32a4f 100644 --- a/backend/backend/core/scheduler/urls.py +++ b/backend/backend/core/scheduler/urls.py @@ -10,6 +10,7 @@ trigger_task_once, trigger_task_once_for_model, list_deploy_candidates, + list_recent_runs_for_model, get_periodic_task, get_model_columns, ) @@ -44,6 +45,11 @@ list_deploy_candidates, name="list_deploy_candidates", ), + path( + "/quick-deploy/recent-runs/", + list_recent_runs_for_model, + name="list_recent_runs_for_model", + ), # Model columns endpoint for incremental job configuration path( "/model//columns", diff --git a/backend/backend/core/scheduler/views.py b/backend/backend/core/scheduler/views.py index ccd34753..3bff4967 100644 --- a/backend/backend/core/scheduler/views.py +++ b/backend/backend/core/scheduler/views.py @@ -696,6 +696,49 @@ def trigger_task_once_for_model(request, project_id, user_task_id, model_name): return _dispatch_task_run(task, request.user.id, models_override=[model_name]) +@api_view(["GET"]) +@permission_classes([IsAuthenticated]) +def list_recent_runs_for_model(request, project_id, model_name): + """Return recent TaskRunHistory entries for any job in this project that + includes ``model_name`` in its ``model_configs``. Mixes scheduled and + quick-deploy runs; caller distinguishes via each row's + ``kwargs.source``. + """ + try: + limit = int(request.GET.get("limit", 5)) + except (TypeError, ValueError): + limit = 5 + limit = max(1, min(limit, 50)) + + runs_qs = TaskRunHistory.objects.select_related( + "user_task_detail", "user_task_detail__environment", + ).filter( + user_task_detail__project__project_uuid=project_id, + user_task_detail__model_configs__has_key=model_name, + ).order_by("-start_time")[:limit] + + data = [] + for run in runs_qs: + task = run.user_task_detail + env = task.environment + kwargs = run.kwargs or {} + data.append({ + "run_id": run.id, + "user_task_id": task.id, + "task_name": task.task_name, + "status": run.status, + "start_time": run.start_time.isoformat() if run.start_time else None, + "end_time": run.end_time.isoformat() if run.end_time else None, + "error_message": run.error_message, + "environment_name": getattr(env, "environment_name", "") + or getattr(env, "name", ""), + "source": kwargs.get("source") or "scheduled", + "models_override": kwargs.get("models_override") or [], + }) + + return Response({"data": data}, status=status.HTTP_200_OK) + + @api_view(["GET"]) @permission_classes([IsAuthenticated]) def list_deploy_candidates(request, project_id, model_name): 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 1c7b627f..d8f7e647 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 @@ -1,5 +1,7 @@ import { Button, + Divider, + Dropdown, Empty, Input, Modal, @@ -9,6 +11,7 @@ import { Space, Table, Tabs, + Tag, theme, Tooltip, Typography, @@ -43,6 +46,7 @@ import { FilterOutlined, LineHeightOutlined, LinkOutlined, + DownOutlined, MergeCellsOutlined, PlayCircleOutlined, PlusSquareOutlined, @@ -246,7 +250,8 @@ function NoCodeModel({ nodeData }) { const { setPendingLineageTab } = useLineageTabStore(); const axiosPrivate = useAxiosPrivate(); const navigate = useNavigate(); - const { listDeployCandidates, runTaskForModel } = useJobService(); + const { listDeployCandidates, runTaskForModel, listRecentRunsForModel } = + useJobService(); const [quickDeployModal, setQuickDeployModal] = useState({ open: false, @@ -255,6 +260,11 @@ function NoCodeModel({ nodeData }) { selectedTaskId: null, submitting: false, }); + const [recentRunsState, setRecentRunsState] = useState({ + loading: false, + runs: [], + fetchedFor: null, // model name the current runs are for + }); const modelName = nodeData?.node?.title || @@ -1645,6 +1655,113 @@ function NoCodeModel({ nodeData }) { setSpecRevert(true); }; + const STATUS_COLOR = { + SUCCESS: "green", + FAILURE: "red", + STARTED: "processing", + RUNNING: "processing", + RETRY: "orange", + }; + + const formatRelativeTime = (iso) => { + if (!iso) return "—"; + const diff = Date.now() - new Date(iso).getTime(); + const mins = Math.floor(diff / 60000); + if (mins < 1) return "just now"; + if (mins < 60) return `${mins} min ago`; + const hrs = Math.floor(mins / 60); + if (hrs < 24) return `${hrs} hr ago`; + const days = Math.floor(hrs / 24); + if (days < 7) return `${days} day${days > 1 ? "s" : ""} ago`; + return new Date(iso).toLocaleDateString(); + }; + + const fetchRecentRuns = async () => { + const name = nodeData?.node?.title; + if (!name) return; + if (recentRunsState.fetchedFor === name && !recentRunsState.loading) return; + setRecentRunsState((prev) => ({ ...prev, loading: true })); + try { + const runs = await listRecentRunsForModel(projectId, name, 5); + setRecentRunsState({ loading: false, runs, fetchedFor: name }); + } catch (error) { + setRecentRunsState((prev) => ({ ...prev, loading: false })); + notify({ error }); + } + }; + + const renderRecentRunsPanel = () => ( +
+
+ Recent runs +
+ {recentRunsState.loading ? ( +
Loading…
+ ) : recentRunsState.runs.length === 0 ? ( +
+ No runs for this model yet. +
+ ) : ( + recentRunsState.runs.map((run) => { + const statusLabel = run.status === "FAILURE" ? "FAILED" : run.status; + return ( +
+ + + {statusLabel} + + + {run.source === "quick_deploy" ? "Quick Deploy" : "Scheduled"} + + + + {run.task_name} + {run.environment_name ? ` · ${run.environment_name}` : ""} + + + {formatRelativeTime(run.start_time)} + +
+ ); + }) + )} + +
+ +
+
+ ); + const openQuickDeploy = async () => { const currentModelName = nodeData?.node?.title; if (!currentModelName) return; @@ -2682,20 +2799,36 @@ function NoCodeModel({ nodeData }) { />
- + + + { + if (open) fetchRecentRuns(); + }} + dropdownRender={renderRecentRunsPanel} + > +