diff --git a/backend/backend/core/scheduler/celery_tasks.py b/backend/backend/core/scheduler/celery_tasks.py index 65c77147..add5a2f1 100644 --- a/backend/backend/core/scheduler/celery_tasks.py +++ b/backend/backend/core/scheduler/celery_tasks.py @@ -180,11 +180,30 @@ 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, + trigger: str = "scheduled", +): """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. + trigger: "scheduled" (default, used by Celery beat) or "manual" + (used by ad-hoc dispatch from trigger_task_once*). Stored in + TaskRunHistory.kwargs alongside ``scope`` so Run History can + distinguish scheduled vs on-demand runs. """ + scope = "model" if models_override else "job" from backend.application.context.application import ApplicationContext from backend.utils.tenant_context import _get_tenant_context from backend.core.models.user_model import User @@ -228,17 +247,23 @@ 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, + "trigger": trigger, + "scope": scope, + } + if models_override: + run_kwargs["models_override"] = list(models_override) + 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 +313,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 +356,16 @@ 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, + "trigger": trigger, + } + 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..c9e32a4f 100644 --- a/backend/backend/core/scheduler/urls.py +++ b/backend/backend/core/scheduler/urls.py @@ -8,6 +8,9 @@ update_periodic_task, task_run_history, trigger_task_once, + trigger_task_once_for_model, + list_deploy_candidates, + list_recent_runs_for_model, get_periodic_task, get_model_columns, ) @@ -32,6 +35,21 @@ 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", + ), + 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 77fa8da5..122ff150 100644 --- a/backend/backend/core/scheduler/views.py +++ b/backend/backend/core/scheduler/views.py @@ -605,30 +605,22 @@ 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. +def _dispatch_task_run(task, user_id, models_override=None): + """Shared dispatch: try Celery broker, fall back to synchronous execution. - Tries Celery first; if the broker is unreachable, falls back to - synchronous (in-process) execution so local dev works without Redis. + Always marks the run as ``trigger="manual"`` — only the Celery beat + scheduler path hits ``trigger_scheduled_run`` without this dispatch + wrapper, and it keeps the default ``trigger="scheduled"``. """ - 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 - ) - 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, + "trigger": "manual", } + 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 +638,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 +652,148 @@ 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_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 {} + models_override = kwargs.get("models_override") or [] + # Back-compat: rows written before the trigger/scope split only + # carried kwargs.source=="quick_deploy" as their manual-model marker. + legacy_source = kwargs.get("source") + trigger = kwargs.get("trigger") or ( + "manual" if legacy_source == "quick_deploy" else "scheduled" + ) + scope = kwargs.get("scope") or ( + "model" if models_override or legacy_source == "quick_deploy" else "job" + ) + 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", ""), + "trigger": trigger, + "scope": scope, + "models_override": models_override, + }) + + return Response({"data": data}, status=status.HTTP_200_OK) + + +@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, + model_configs__has_key=model_name, + ) + + candidates = [] + for task in tasks: + model_configs = task.model_configs or {} + cfg = model_configs.get(model_name) + if not cfg or not cfg.get("enabled", True): + continue + enabled_model_count = sum( + 1 + for m_cfg in model_configs.values() + if isinstance(m_cfg, dict) and m_cfg.get("enabled", True) + ) + env = task.environment + candidates.append({ + "user_task_id": task.id, + "task_name": task.task_name, + "environment_id": str(env.environment_id) if env else "", + "environment_name": ( + getattr(env, "environment_name", "") + or getattr(env, "name", "") + ) if env else "", + "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, + "enabled_model_count": enabled_model_count, + }) + + 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..9bfee9be 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,17 +1,24 @@ import { Button, + Card, + Checkbox, + Divider, + Dropdown, Empty, Input, Modal, Pagination, + Radio, Select, Space, Table, Tabs, + Tag, theme, 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"; @@ -41,7 +48,9 @@ import { FilterOutlined, LineHeightOutlined, LinkOutlined, + DownOutlined, MergeCellsOutlined, + PlayCircleOutlined, PlusSquareOutlined, ProfileOutlined, RetweetOutlined, @@ -81,6 +90,7 @@ import { addIdToObjects, checkPermission, getBaseUrl, + getRelativeTime, removeIdFromObjects, removeUnwantedKeys, } from "../../../common/helpers.js"; @@ -98,6 +108,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 +252,28 @@ function NoCodeModel({ nodeData }) { const { renamedModel, setRenamedModel } = useProjectStore(); const { setPendingLineageTab } = useLineageTabStore(); const axiosPrivate = useAxiosPrivate(); + const navigate = useNavigate(); + const { + listDeployCandidates, + runTaskForModel, + runTask, + listRecentRunsForModel, + } = useJobService(); + + const [quickDeployModal, setQuickDeployModal] = useState({ + open: false, + step: "loading", // loading | empty | confirm | pick + candidates: [], + selectedTaskId: null, + selectedScope: null, // "model" | "job" + confirmed: false, + submitting: false, + }); + const [recentRunsState, setRecentRunsState] = useState({ + loading: false, + runs: [], + fetchedFor: null, // model name the current runs are for + }); const modelName = nodeData?.node?.title || @@ -1631,6 +1664,197 @@ function NoCodeModel({ nodeData }) { setSpecRevert(true); }; + const STATUS_COLOR = { + SUCCESS: "green", + FAILURE: "red", + STARTED: "processing", + RUNNING: "processing", + RETRY: "orange", + }; + + 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.trigger === "manual" ? "Manual" : "Scheduled"} + + + {run.scope === "model" ? "Single model" : "Full job"} + + + + {run.task_name} + {run.environment_name ? ` · ${run.environment_name}` : ""} + + + {getRelativeTime(run.start_time)} + +
+ ); + }) + )} + +
+ +
+
+ ); + + const openQuickDeploy = async () => { + const currentModelName = nodeData?.node?.title; + if (!currentModelName) return; + setQuickDeployModal({ + open: true, + step: "loading", + candidates: [], + selectedTaskId: null, + selectedScope: null, + confirmed: false, + 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, selectedScope, confirmed } = quickDeployModal; + if (!currentModelName || !selectedTaskId || !selectedScope || !confirmed) { + return; + } + setQuickDeployModal((prev) => ({ ...prev, submitting: true })); + try { + if (selectedScope === "job") { + await runTask(projectId, selectedTaskId); + } else { + await runTaskForModel(projectId, selectedTaskId, currentModelName); + } + const selected = quickDeployModal.candidates.find( + (c) => c.user_task_id === selectedTaskId + ); + const envName = selected?.environment_name || "the selected environment"; + const jobName = selected?.task_name || ""; + notify({ + type: "success", + message: "Deploy Triggered", + description: + selectedScope === "job" + ? `Job "${jobName}" is running on "${envName}" (all enabled models). Check Run History for progress.` + : `"${currentModelName}" is running on "${envName}" via job "${jobName}". Check Run History for progress.`, + }); + setRefreshModels(true); + setRecentRunsState((prev) => ({ ...prev, fetchedFor: null })); + 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 +2810,36 @@ function NoCodeModel({ nodeData }) { />
+ + + { + if (open) fetchRecentRuns(); + }} + dropdownRender={renderRecentRunsPanel} + > +
)} + + + + + ) : 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.step === "pick") && + (() => { + const selected = quickDeployModal.candidates.find( + (c) => c.user_task_id === quickDeployModal.selectedTaskId + ); + const multiple = quickDeployModal.candidates.length > 1; + const modelCount = selected?.enabled_model_count || 0; + return ( +
+ {multiple && ( + <> + Pick a job + + setQuickDeployModal((prev) => ({ + ...prev, + selectedTaskId: e.target.value, + selectedScope: null, + confirmed: false, + })) + } + style={{ + display: "flex", + flexDirection: "column", + gap: 8, + margin: "8px 0 16px", + }} + > + {quickDeployModal.candidates.map((c) => ( + + {c.task_name} + + env: {c.environment_name || "(unnamed)"} + + + ))} + + + )} + {!multiple && selected && ( + + Job {selected.task_name} · environment{" "} + {selected.environment_name || "(unnamed)"} + + )} + Choose scope +
+ {[ + { + key: "model", + title: "Run this model only", + body: ( + <> + Fast — runs just{" "} + {nodeData?.node?.title || "this model"}. + + ), + }, + { + key: "job", + title: "Run full job", + body: ( + <> + Runs all {modelCount || "enabled"} model + {modelCount === 1 ? "" : "s"} in the job. + + ), + }, + ].map((opt) => { + const isSelected = + quickDeployModal.selectedScope === opt.key; + return ( + + setQuickDeployModal((prev) => ({ + ...prev, + selectedScope: opt.key, + confirmed: false, + })) + } + styles={{ body: { padding: 12 } }} + style={{ + cursor: quickDeployModal.submitting + ? "not-allowed" + : "pointer", + borderColor: isSelected + ? token.colorPrimary + : undefined, + boxShadow: isSelected + ? `0 0 0 2px ${token.controlOutline}` + : undefined, + }} + > + {opt.title} +
+ + {opt.body} + +
+
+ ); + })} +
+
+ + setQuickDeployModal((prev) => ({ + ...prev, + confirmed: e.target.checked, + })) + } + > + I understand this will{" "} + {quickDeployModal.selectedScope === "job" + ? `run all ${modelCount || "enabled"} model${ + modelCount === 1 ? "" : "s" + } in "${selected?.task_name || "this job"}"` + : quickDeployModal.selectedScope === "model" + ? `run "${nodeData?.node?.title || "this model"}" via "${ + selected?.task_name || "the selected job" + }"` + : "run the selected scope"}{" "} + against environment{" "} + {selected?.environment_name || "(unnamed)"} + . + +
+
+ ); + })()} +
); } diff --git a/frontend/src/ide/run-history/RunHistory.css b/frontend/src/ide/run-history/RunHistory.css index 9ce7f222..7e7e8956 100644 --- a/frontend/src/ide/run-history/RunHistory.css +++ b/frontend/src/ide/run-history/RunHistory.css @@ -39,6 +39,18 @@ overflow: auto; } -.runhistory-error-row { - padding: 4px 0; +/* Visually bind an expanded error row to its parent run row so the + * error panel reads as a continuation of that row, not a sibling. */ +.runhistory-table-container .runhistory-row-expanded > td { + border-bottom-color: transparent !important; +} + +.runhistory-table-container .ant-table-expanded-row > td { + border-top: 0 !important; + padding: 0 !important; + background: transparent !important; +} + +.runhistory-table-container .ant-table-expanded-row:hover > td { + background: transparent !important; } diff --git a/frontend/src/ide/run-history/Runhistory.jsx b/frontend/src/ide/run-history/Runhistory.jsx index b4738067..310b09f0 100644 --- a/frontend/src/ide/run-history/Runhistory.jsx +++ b/frontend/src/ide/run-history/Runhistory.jsx @@ -1,8 +1,10 @@ import { useEffect, useState, useMemo, useCallback } from "react"; import { + Alert, Select, Table, Tag, + theme, Typography, Empty, Button, @@ -13,6 +15,7 @@ import { ReloadOutlined, CalendarOutlined, DatabaseOutlined, + CloseCircleFilled, } from "@ant-design/icons"; import { useAxiosPrivate } from "../../service/axios-service"; @@ -58,6 +61,16 @@ const STATUS_OPTIONS = [ { label: "Revoked", value: "REVOKED" }, ]; +const getRunTriggerScope = (row) => { + const kw = row?.kwargs || {}; + const legacyQuick = kw.source === "quick_deploy"; + const models = kw.models_override || []; + const trigger = kw.trigger || (legacyQuick ? "manual" : "scheduled"); + const scope = + kw.scope || (models.length > 0 || legacyQuick ? "model" : "job"); + return { trigger, scope, models }; +}; + const Runhistory = () => { const axios = useAxiosPrivate(); const { @@ -76,7 +89,10 @@ const Runhistory = () => { const [filterQueries, setFilterQuery] = useState({ status: "", job: "", + trigger: "", + scope: "", }); + const [envInfo, setEnvInfo] = useState({ env_type: "", job_name: "", @@ -84,6 +100,7 @@ const Runhistory = () => { }); const [loading, setLoading] = useState(false); const { selectedOrgId } = orgStore(); + const { token } = theme.useToken(); const { notify } = useNotificationService(); /* ─── API calls ─── */ @@ -150,31 +167,52 @@ const Runhistory = () => { getJobList(); }, []); - /* ─── client-side status filter ─── */ + /* ─── client-side status + trigger + scope filters ─── */ useEffect(() => { + let filtered = backUpData; if (filterQueries.status) { - setJobHistoryData( - backUpData.filter((el) => el.status === filterQueries.status) + filtered = filtered.filter((el) => el.status === filterQueries.status); + } + if (filterQueries.trigger) { + filtered = filtered.filter( + (el) => getRunTriggerScope(el).trigger === filterQueries.trigger ); - } else { - setJobHistoryData(backUpData); } - }, [filterQueries.status, backUpData]); + if (filterQueries.scope) { + filtered = filtered.filter( + (el) => getRunTriggerScope(el).scope === filterQueries.scope + ); + } + setJobHistoryData(filtered); + }, [ + filterQueries.status, + filterQueries.trigger, + filterQueries.scope, + backUpData, + ]); - /* ─── auto-expand failed rows ─── */ + /* ─── auto-expand failed rows on fresh data load ─── */ useEffect(() => { - const failedIds = (JobHistoryData || []) + const failedIds = (backUpData || []) .filter((r) => r.status === "FAILURE" && r.error_message) .map((r) => r.id); setExpandedRowKeys(failedIds); - }, [JobHistoryData]); + }, [backUpData]); /* ─── handlers ─── */ const handleJobChange = useCallback((value) => { - setFilterQuery({ status: "", job: value }); + setFilterQuery({ status: "", job: value, trigger: "", scope: "" }); getRunHistoryList(value); }, []); + const handleTriggerChange = useCallback((value) => { + setFilterQuery((prev) => ({ ...prev, trigger: value || "" })); + }, []); + + const handleScopeChange = useCallback((value) => { + setFilterQuery((prev) => ({ ...prev, scope: value || "" })); + }, []); + const handleStatusChange = useCallback((value) => { setFilterQuery((prev) => ({ ...prev, status: value || "" })); }, []); @@ -216,6 +254,40 @@ const Runhistory = () => { ), }, + { + title: "Trigger", + key: "trigger", + width: 120, + render: (_, record) => { + const { trigger } = getRunTriggerScope(record); + return trigger === "manual" ? ( + Manual + ) : ( + Scheduled + ); + }, + }, + { + title: "Scope", + key: "scope", + width: 220, + render: (_, record) => { + const { scope, models } = getRunTriggerScope(record); + if (scope === "model") { + return ( + + Single model + {models.length > 0 && ( + + {models.join(", ")} + + )} + + ); + } + return Full job; + }, + }, { title: "Triggered", dataIndex: "start_time", @@ -252,9 +324,16 @@ const Runhistory = () => { const emptyDescription = useMemo(() => { if (!jobListItems.length) return "No jobs created yet"; if (!filterQueries.job) return "Select a job to view run history"; - if (filterQueries.status) return "No matching runs found"; + if (filterQueries.status || filterQueries.trigger || filterQueries.scope) + return "No matching runs found"; return "No run history available"; - }, [jobListItems.length, filterQueries.job, filterQueries.status]); + }, [ + jobListItems.length, + filterQueries.job, + filterQueries.status, + filterQueries.trigger, + filterQueries.scope, + ]); return (
@@ -283,6 +362,28 @@ const Runhistory = () => { options={STATUS_OPTIONS} value={filterQueries.status || undefined} /> +