diff --git a/backend/backend/core/scheduler/views.py b/backend/backend/core/scheduler/views.py index 122ff150..c0c341e1 100644 --- a/backend/backend/core/scheduler/views.py +++ b/backend/backend/core/scheduler/views.py @@ -20,6 +20,21 @@ logger = logging.getLogger(__name__) + +def _compute_next_run_time(periodic, last_run_at): + """Derive the next run time from a PeriodicTask's schedule.""" + if not periodic or not periodic.enabled: + return None + try: + schedule = periodic.schedule + reference = last_run_at or periodic.last_run_at or timezone.now() + remaining = schedule.remaining_estimate(reference) + return timezone.now() + remaining + except Exception: + logger.debug("Failed to compute next_run_time for %s", periodic, exc_info=True) + return None + + def _is_valid_project_id(project_id): """Check if project_id is a real UUID (not a placeholder like '_all' or 'all').""" try: @@ -164,6 +179,9 @@ def _serialize_task(task): "task_status": task.status, "task_run_time": task.task_run_time, "task_completion_time": task.task_completion_time, + "next_run_time": task.next_run_time or _compute_next_run_time( + periodic, task.task_run_time + ), "task_type": task_type, "description": task.description, "environment": { diff --git a/frontend/src/common/helpers.js b/frontend/src/common/helpers.js index 83a1afe6..8d00ccbe 100644 --- a/frontend/src/common/helpers.js +++ b/frontend/src/common/helpers.js @@ -354,15 +354,31 @@ const getRelativeTime = (dateString) => { const now = new Date(); const then = new Date(dateString); const diffMs = now - then; - const diffMins = Math.floor(diffMs / 60000); - if (diffMins < 1) return "just now"; - if (diffMins < 60) return `${diffMins}m ago`; - const diffHrs = Math.floor(diffMins / 60); - if (diffHrs < 24) return `${diffHrs}h ago`; - const diffDays = Math.floor(diffHrs / 24); - if (diffDays < 30) return `${diffDays}d ago`; - const diffMonths = Math.floor(diffDays / 30); - return `${diffMonths}mo ago`; + const isPast = diffMs >= 0; + const absMs = Math.abs(diffMs); + const mins = Math.floor(absMs / 60000); + const fmt = (n, unit) => (isPast ? `${n}${unit} ago` : `in ${n}${unit}`); + if (mins < 1) return isPast ? "just now" : "in a moment"; + if (mins < 60) return fmt(mins, "m"); + const hrs = Math.floor(mins / 60); + if (hrs < 24) return fmt(hrs, "h"); + const days = Math.floor(hrs / 24); + if (days < 30) return fmt(days, "d"); + const months = Math.floor(days / 30); + return fmt(months, "mo"); +}; + +const formatDateTime = (dateString) => { + if (!dateString) return ""; + const d = new Date(dateString); + if (Number.isNaN(d.getTime())) return ""; + return d.toLocaleString(undefined, { + month: "short", + day: "numeric", + year: "numeric", + hour: "numeric", + minute: "2-digit", + }); }; export { @@ -388,4 +404,5 @@ export { extractFormulaExpression, validateFormulaExpression, getRelativeTime, + formatDateTime, }; diff --git a/frontend/src/ide/run-history/Runhistory.jsx b/frontend/src/ide/run-history/Runhistory.jsx index 310b09f0..2bd50b06 100644 --- a/frontend/src/ide/run-history/Runhistory.jsx +++ b/frontend/src/ide/run-history/Runhistory.jsx @@ -15,15 +15,23 @@ import { ReloadOutlined, CalendarOutlined, DatabaseOutlined, + CheckCircleFilled, CloseCircleFilled, + ClockCircleOutlined, + SyncOutlined, } from "@ant-design/icons"; +import { useSearchParams } from "react-router-dom"; import { useAxiosPrivate } from "../../service/axios-service"; import { orgStore } from "../../store/org-store"; import { useNotificationService } from "../../service/notification-service"; import { runHistoryTagColor } from "../../common/constants"; import { usePagination } from "../../widgets/hooks/usePagination"; -import { getTooltipText } from "../../common/helpers"; +import { + getTooltipText, + getRelativeTime, + formatDateTime, +} from "../../common/helpers"; import "./RunHistory.css"; /* ─── Parse duration string to milliseconds for sorting ─── */ @@ -102,35 +110,35 @@ const Runhistory = () => { const { selectedOrgId } = orgStore(); const { token } = theme.useToken(); const { notify } = useNotificationService(); + const [searchParams, setSearchParams] = useSearchParams(); /* ─── API calls ─── */ - const getRunHistoryList = async ( - Id, - page = currentPage, - limit = pageSize - ) => { - setLoading(true); - try { - const res = await axios({ - method: "GET", - url: `/api/v1/visitran/${ - selectedOrgId || "default_org" - }/project/_all/jobs/run-history/${Id}`, - params: { page, limit }, - }); - const { page_items, total_items, current_page } = res.data.data; - setTotalCount(total_items); - setCurrentPage(current_page); - const { env_type, job_name, run_history, id } = page_items; - setEnvInfo({ env_type, job_name, id }); - setJobHistoryData(run_history); - setBackUpData(run_history); - } catch (error) { - notify({ error }); - } finally { - setLoading(false); - } - }; + const getRunHistoryList = useCallback( + async (Id, page = currentPage, limit = pageSize) => { + setLoading(true); + try { + const res = await axios({ + method: "GET", + url: `/api/v1/visitran/${ + selectedOrgId || "default_org" + }/project/_all/jobs/run-history/${Id}`, + params: { page, limit }, + }); + const { page_items, total_items, current_page } = res.data.data; + setTotalCount(total_items); + setCurrentPage(current_page); + const { env_type, job_name, run_history, id } = page_items; + setEnvInfo({ env_type, job_name, id }); + setJobHistoryData(run_history); + setBackUpData(run_history); + } catch (error) { + notify({ error }); + } finally { + setLoading(false); + } + }, + [axios, selectedOrgId, currentPage, pageSize, notify] + ); const getJobList = async () => { setLoading(true); @@ -144,17 +152,26 @@ const Runhistory = () => { const { page_items } = res.data.data; const scheduledObj = {}; const jobIds = page_items.map((el) => { - scheduledObj[el.user_task_id] = getTooltipText( - el.periodic_task_details[el.task_type], - el.task_type - ); + const taskDetails = el.periodic_task_details?.[el.task_type]; + if (taskDetails) { + scheduledObj[el.user_task_id] = getTooltipText( + taskDetails, + el.task_type + ); + } return { label: el.task_name, value: el.user_task_id }; }); setJobSchedule(scheduledObj); setJobListItems(jobIds); if (jobIds.length) { - setFilterQuery((prev) => ({ ...prev, job: jobIds[0].value })); - getRunHistoryList(jobIds[0].value); + const taskFromUrl = searchParams.get("task"); + const taskFromUrlNum = taskFromUrl ? Number(taskFromUrl) : NaN; + const matchedFromUrl = !Number.isNaN(taskFromUrlNum) + ? jobIds.find((j) => j.value === taskFromUrlNum) + : null; + const initial = matchedFromUrl?.value ?? jobIds[0].value; + setFilterQuery((prev) => ({ ...prev, job: initial })); + getRunHistoryList(initial); } } catch (error) { console.error("Failed to load jobs", error); @@ -191,7 +208,7 @@ const Runhistory = () => { backUpData, ]); - /* ─── auto-expand failed rows on fresh data load ─── */ + /* ─── auto-expand failed rows on fresh data load (not on filter changes) ─── */ useEffect(() => { const failedIds = (backUpData || []) .filter((r) => r.status === "FAILURE" && r.error_message) @@ -200,10 +217,22 @@ const Runhistory = () => { }, [backUpData]); /* ─── handlers ─── */ - const handleJobChange = useCallback((value) => { - setFilterQuery({ status: "", job: value, trigger: "", scope: "" }); - getRunHistoryList(value); - }, []); + const handleJobChange = useCallback( + (value) => { + setFilterQuery({ status: "", job: value, trigger: "", scope: "" }); + getRunHistoryList(value); + setSearchParams( + (prev) => { + const next = new URLSearchParams(prev); + if (value) next.set("task", String(value)); + else next.delete("task"); + return next; + }, + { replace: true } + ); + }, + [setSearchParams, getRunHistoryList] + ); const handleTriggerChange = useCallback((value) => { setFilterQuery((prev) => ({ ...prev, trigger: value || "" })); @@ -298,9 +327,25 @@ const Runhistory = () => { return new Date(a.start_time) - new Date(b.start_time); }, defaultSortOrder: "descend", - render: (text) => ( - {text || "Not started yet"} - ), + render: (text) => { + if (!text) { + return ( + + Not started yet + + ); + } + return ( + + + {formatDateTime(text)} + + {getRelativeTime(text)} + + + + ); + }, }, { title: "Duration", @@ -320,6 +365,54 @@ const Runhistory = () => { [] ); + const STATUS_META = useMemo( + () => ({ + SUCCESS: { + icon: , + label: "Succeeded", + color: token.colorSuccess, + bg: token.colorSuccessBg, + }, + FAILURE: { + icon: , + label: "Failed", + color: token.colorError, + bg: token.colorErrorBg, + }, + STARTED: { + icon: , + label: "Running", + color: token.colorInfo, + bg: token.colorInfoBg, + }, + RUNNING: { + icon: , + label: "Running", + color: token.colorInfo, + bg: token.colorInfoBg, + }, + RETRY: { + icon: , + label: "Retrying", + color: token.colorWarning, + bg: token.colorWarningBg, + }, + REVOKED: { + icon: , + label: "Revoked", + color: token.colorTextSecondary, + bg: token.colorFillQuaternary, + }, + PENDING: { + icon: , + label: "Pending", + color: token.colorTextSecondary, + bg: token.colorFillQuaternary, + }, + }), + [token] + ); + /* ─── empty text ─── */ const emptyDescription = useMemo(() => { if (!jobListItems.length) return "No jobs created yet"; @@ -437,56 +530,94 @@ const Runhistory = () => { : {} } expandable={{ - expandedRowRender: (record) => ( -
+ expandedRowRender: (record) => { + const meta = STATUS_META[record.status] || STATUS_META.PENDING; + const { scope, models } = getRunTriggerScope(record); + const isFailure = record.status === "FAILURE"; + return (
- - - Error from this run - - {record.start_time && ( +
+ {meta.icon} + + {meta.label} + + {record.start_time && ( + + + · {formatDateTime(record.start_time)} ( + {getRelativeTime(record.start_time)}) + + + )} + {record.duration && ( + + · {formatDuration(record.duration)} + + )} +
+ + + {scope === "model" ? "Single model" : "Full job"} + - · {record.start_time} + {models.length > 0 + ? `Models attempted: ${models.join(", ")}` + : "No model configuration recorded for this run."} + + {isFailure && record.error_message && ( + + {record.error_message} + + } + /> )}
- - {record.error_message} - - } - /> -
- ), + ); + }, rowExpandable: (record) => - record.status === "FAILURE" && !!record.error_message, + ["SUCCESS", "FAILURE", "RETRY", "REVOKED"].includes( + record.status + ), expandedRowKeys, onExpand: handleExpand, }} diff --git a/frontend/src/ide/scheduler/JobDeploy.jsx b/frontend/src/ide/scheduler/JobDeploy.jsx index 5c28c079..cfd111a6 100644 --- a/frontend/src/ide/scheduler/JobDeploy.jsx +++ b/frontend/src/ide/scheduler/JobDeploy.jsx @@ -21,6 +21,7 @@ import { Divider, Spin, Collapse, + Tooltip, } from "antd"; import { ClockCircleOutlined, @@ -32,6 +33,8 @@ import { LinkOutlined, ExpandAltOutlined, ShrinkOutlined, + PlusOutlined, + ReloadOutlined, } from "@ant-design/icons"; import { checkPermission } from "../../common/helpers"; @@ -459,7 +462,35 @@ const JobDeploy = memo(function JobDeploy({ + Environment + + + + ), }, { @@ -124,53 +160,76 @@ const JobListTable = memo( ), }, { - title: "Schedule Type", + title: "Schedule", key: "schedule", - render: (_, record) => ( - - {getTooltipText( - record.periodic_task_details?.[record.task_type] ?? {}, - record.task_type - )} - {record.task_status === "FAILED" && ( - <> -
- Logs available in Run History - - )} - - } - > - { + const scheduleText = getTooltipText( + record.periodic_task_details?.[record.task_type] ?? {}, + record.task_type + ); + return ( + + }> + {record.task_type === "interval" ? "Interval" : "Cron"} + + + {scheduleText} + + + ); + }, + }, + { + title: "Last Run Status", + key: "last_run_status", + render: (_, record) => { + if (!record.task_status) { + return ; + } + const isFailed = [ + "FAILED", + "FAILED PERMANENTLY", + "FAILURE", + ].includes(record.task_status); + return ( + } > - {record.task_status} - -
- ), + + {record.task_status === "FAILURE" + ? "FAILED" + : record.task_status} + + + ); + }, }, { title: "Last Run", dataIndex: "task_completion_time", key: "last_run", - render: (text) => ( - {text || "Not started yet."} - ), + render: renderDateCell, }, { title: "Next Run", - dataIndex: "task_run_time", + dataIndex: "next_run_time", key: "next_run", - render: (text) => ( - {text || "Not started yet."} - ), + render: renderDateCell, }, { title: "Status", diff --git a/frontend/src/ide/scheduler/ModelConfigsTable.jsx b/frontend/src/ide/scheduler/ModelConfigsTable.jsx index c11df5f8..c77143db 100644 --- a/frontend/src/ide/scheduler/ModelConfigsTable.jsx +++ b/frontend/src/ide/scheduler/ModelConfigsTable.jsx @@ -6,6 +6,7 @@ import { Switch, Button, Space, + Tooltip, Typography, Spin, Empty, @@ -15,6 +16,7 @@ import { EyeOutlined, ThunderboltOutlined, ReloadOutlined, + InfoCircleOutlined, } from "@ant-design/icons"; import { useJobService } from "./service"; @@ -28,24 +30,53 @@ const MATERIALIZATION_OPTIONS = [ label: "Table", icon: , color: "blue", - description: "Full table refresh on each run", + description: + "Drops and rebuilds the destination table on every run. Slowest on large data but always consistent. Good default when unsure.", }, { value: "VIEW", label: "View", icon: , color: "green", - description: "Virtual view, no data stored", + description: + "Stores the transformation as a SQL view — no data is written. Fast to build but every downstream read re-executes the query. Pick for lightweight transforms or small data.", }, { value: "INCREMENTAL", label: "Incremental", icon: , color: "orange", - description: "Process only new/changed data", + description: + "Appends/merges only new or changed rows using a watermark (primary key + delta column). Fastest on large, append-only data — but needs a reliable delta column and the right primary key.", }, ]; +const MATERIALIZATION_COLUMN_HELP = ( +
+ How a model gets written to the warehouse +
    +
  • + Table — full rebuild every run. Safest, slowest on + large data. +
  • +
  • + View — stored as SQL only; nothing materialized. Cheap, + but downstream reads re-run the query. +
  • +
  • + Incremental — only new/changed rows appended via a + watermark. Fastest on large data; needs a valid primary key + delta + column. +
  • +
+
+ Switching materialization later is safe, but the first run after a switch + rebuilds the destination from scratch (and an Incremental model needs its + watermark reset). +
+
+); + const ModelConfigsTable = ({ models, modelConfigs, @@ -85,10 +116,14 @@ const ModelConfigsTable = ({ } }, [models]); - // Fetch columns for a model when needed (no environmentId required) + // Fetch columns for a model when needed (no environmentId required). + // Pass { force: true } from Refresh to bypass the cache check; clearing + // columnCache + calling this normally hits a stale-closure race where the + // pre-deletion snapshot still reads as populated and the refetch no-ops. const fetchColumnsForModel = useCallback( - async (modelName) => { - if (!projectId || columnCache[modelName]) return; + async (modelName, { force = false } = {}) => { + if (!projectId) return; + if (!force && columnCache[modelName]) return; setLoadingColumns((prev) => ({ ...prev, [modelName]: true })); @@ -211,14 +246,37 @@ const ModelConfigsTable = ({ } disabled={disabled || !config.enabled} style={{ width: 150 }} - popupMatchSelectWidth={false} + popupMatchSelectWidth={320} + optionLabelProp="label" > {MATERIALIZATION_OPTIONS.map((opt) => ( - - - {opt.icon} - {opt.label} - + + {opt.icon} + {opt.label} + + } + > +
+ + {opt.icon} + {opt.label} + +
+ {opt.description} +
+
))} @@ -256,9 +314,16 @@ const ModelConfigsTable = ({ ), }, { - title: "Materialization", + title: ( + + Materialization + + + + + ), key: "materialization", - width: 180, + width: 200, render: (_, record) => renderMaterializationSelect(record), }, ]; @@ -291,12 +356,7 @@ const ModelConfigsTable = ({ type="text" icon={} onClick={() => { - setColumnCache((prev) => { - const updated = { ...prev }; - delete updated[record.modelName]; - return updated; - }); - fetchColumnsForModel(record.modelName); + fetchColumnsForModel(record.modelName, { force: true }); }} disabled={isLoading} >