diff --git a/backend/backend/core/scheduler/views.py b/backend/backend/core/scheduler/views.py index 195fea5..8099b28 100644 --- a/backend/backend/core/scheduler/views.py +++ b/backend/backend/core/scheduler/views.py @@ -23,17 +23,17 @@ -def _compute_next_run_time(periodic, last_run_at): +def _compute_next_run_time(periodic): """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 + now = timezone.now() + remaining = schedule.remaining_estimate(now) + return now + remaining except Exception: - logger.debug("Failed to compute next_run_time for %s", periodic, exc_info=True) + logger.warning("Failed to compute next_run_time for %s", periodic, exc_info=True) return None @@ -181,9 +181,7 @@ 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 - ), + "next_run_time": _compute_next_run_time(periodic) or task.next_run_time, "task_type": task_type, "description": task.description, "environment": { diff --git a/frontend/src/ide/scheduler/JobDeploy.css b/frontend/src/ide/scheduler/JobDeploy.css index 7d2eeb3..24c5854 100644 --- a/frontend/src/ide/scheduler/JobDeploy.css +++ b/frontend/src/ide/scheduler/JobDeploy.css @@ -2,6 +2,55 @@ font-weight: bold; } +/* ── Job List Table ── */ +.jl-job-name { + display: flex; + align-items: center; + gap: 8px; +} + +.jl-job-icon { + width: 30px; + height: 30px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + font-size: 16px; +} + +.jl-job-icon.success { + background: rgba(82, 196, 26, 0.12); + color: var(--success-color, #52c41a); +} + +.jl-job-icon.failed { + background: rgba(255, 77, 79, 0.12); + color: var(--error-color, #ff4d4f); +} + +.jl-job-icon.running { + background: rgba(22, 119, 255, 0.12); + color: var(--primary-color, #1677ff); + animation: jl-pulse 2s infinite; +} + +.jl-job-icon.paused { + background: rgba(140, 140, 140, 0.12); + color: var(--font-color-3, #8c8c8c); +} + +@keyframes jl-pulse { + 0%, + 100% { + box-shadow: 0 0 0 0 rgba(22, 119, 255, 0.4); + } + 50% { + box-shadow: 0 0 0 6px rgba(22, 119, 255, 0); + } +} + .job-deploy-header-field { width: 200px; } diff --git a/frontend/src/ide/scheduler/JobList.jsx b/frontend/src/ide/scheduler/JobList.jsx index 7bca882..a44b488 100644 --- a/frontend/src/ide/scheduler/JobList.jsx +++ b/frontend/src/ide/scheduler/JobList.jsx @@ -1,6 +1,23 @@ +/* eslint-disable eqeqeq, react/prop-types */ import { useEffect, useState, useCallback, useMemo } from "react"; -import { Alert, Button, Space, Typography, Modal, Pagination } from "antd"; -import debounce from "lodash/debounce"; +import { + Alert, + Button, + Space, + Typography, + Modal, + Pagination, + Card, + Row, + Col, + theme, +} from "antd"; +import { + CheckCircleFilled, + CloseCircleFilled, + ThunderboltOutlined, + PlusOutlined, +} from "@ant-design/icons"; import { useNavigate, useSearchParams } from "react-router-dom"; import { checkPermission } from "../../common/helpers"; @@ -13,6 +30,8 @@ import { JobDeploy } from "./JobDeploy.jsx"; import "./JobDeploy.css"; +const { Text, Title } = Typography; + let useSubscriptionDetailsStoreSafe; try { useSubscriptionDetailsStoreSafe = @@ -21,10 +40,48 @@ try { useSubscriptionDetailsStoreSafe = null; } +/* ── StatCard ── */ +const StatCard = ({ label, icon, value, valueColor, subtext }) => ( + + + + {icon} + {icon ? " " : ""} + {label} + +
+ {value} +
+ {subtext &&
{subtext}
} +
+
+); + const JobList = () => { const navigate = useNavigate(); const { listPeriodicTasks, getProjects, deleteTask } = useJobService(); const { notify } = useNotificationService(); + const { token } = theme.useToken(); const [delTaskDetail, setDelTaskDetail] = useState({ projectId: "", taskId: "", @@ -41,7 +98,13 @@ const JobList = () => { const [searchParams, setSearchParams] = useSearchParams(); const [prefillModel, setPrefillModel] = useState(null); const [prefillProject, setPrefillProject] = useState(null); - const [filters, setFilters] = useState({ proj: "all", env: "all" }); + const [filters, setFilters] = useState({ + proj: "all", + env: "all", + status: "", + lastRun: "", + schedule: "", + }); const { currentPage, pageSize, @@ -64,9 +127,7 @@ const JobList = () => { limit = pageSize, showLoader = true, } = {}) => { - if (showLoader) { - setTableLoading(true); - } + if (showLoader) setTableLoading(true); try { const tasks = await listPeriodicTasks(page, limit); const { page_items, total_items, current_page } = tasks.data; @@ -78,9 +139,7 @@ const JobList = () => { } catch (error) { notify({ error }); } finally { - if (showLoader) { - setTableLoading(false); - } + if (showLoader) setTableLoading(false); } }; @@ -88,10 +147,7 @@ const JobList = () => { try { const data = await getProjects(); setProjects( - data.map((el) => ({ - label: el.project_name, - value: el.project_name, - })) + data.map((el) => ({ label: el.project_name, value: el.project_name })) ); } catch (error) { notify({ error }); @@ -102,54 +158,57 @@ const JobList = () => { fetchJobs({ showLoader: true }); fetchProjects(); }, []); - useEffect(() => { if (!isJobListModified) return; fetchJobs(); setIsJobListModified(false); }, [isJobListModified]); - const runSearch = useMemo( - () => - debounce((text) => { - const term = text.toLowerCase(); - setJobList( - backup.filter( - ({ task_name, project }) => - task_name?.toLowerCase().startsWith(term) || - project?.name?.toLowerCase().startsWith(term) - ) - ); - }, 300), - [backup] - ); - - useEffect(() => () => runSearch.cancel(), [runSearch]); // cancel debounce on unmount - - const onSearchChange = (e) => { - const value = e.target.value; - setSearchQuery(value); - runSearch(value); - }; - - const filterBy = useCallback((query, data, type) => { - if (query === "all") return data; - return data.filter((el) => - type === "env" - ? el.environment?.type === query - : el.project?.name === query - ); - }, []); + const onSearchChange = (e) => setSearchQuery(e.target.value); + // Client-side filtering useEffect(() => { - const { env, proj } = filters; let filtered = backup; - - filtered = filterBy(proj, filtered, "proj"); - filtered = filterBy(env, filtered, "env"); - + const { env, proj, status, schedule, lastRun } = filters; + if (proj !== "all") + filtered = filtered.filter((el) => el.project?.name === proj); + if (env !== "all") + filtered = filtered.filter((el) => el.environment?.type === env); + if (status) { + filtered = filtered.filter((el) => { + if (status === "FAILED") + return ["FAILED", "FAILURE", "FAILED PERMANENTLY"].includes( + el.task_status + ); + if (status === "RUNNING") + return ["RUNNING", "STARTED", "PENDING"].includes(el.task_status); + return el.task_status === status; + }); + } + if (schedule) filtered = filtered.filter((el) => el.task_type === schedule); + if (lastRun) { + const windowMs = { "24h": 86400000, "7d": 604800000, "30d": 2592000000 }[ + lastRun + ]; + if (windowMs) { + const cutoff = Date.now() - windowMs; + filtered = filtered.filter( + (el) => + el.task_completion_time && + new Date(el.task_completion_time) >= cutoff + ); + } + } + if (searchQuery) { + const term = searchQuery.toLowerCase(); + filtered = filtered.filter( + ({ task_name, project }) => + task_name?.toLowerCase().includes(term) || + project?.name?.toLowerCase().includes(term) + ); + } setJobList(filtered); - }, [filters, backup, filterBy]); + }, [filters, backup, searchQuery]); const handleRowClick = useCallback((id) => { setOpenJobDeploy(true); @@ -177,15 +236,17 @@ const JobList = () => { try { await deleteTask(delTaskDetail.projectId, delTaskDetail.taskId); setIsDeleteModalOpen(false); - notify({ - type: "success", - message: `Job Deleted Successfully`, - }); - setJobList( - jobList.filter( + notify({ type: "success", message: "Job deleted successfully" }); + const remaining = jobList.filter( + (el) => el.periodic_task_details.id !== delTaskDetail.taskId + ); + setJobList(remaining); + setBackup((prev) => + prev.filter( (el) => el.periodic_task_details.id !== delTaskDetail.taskId ) ); + setTotalCount((prev) => Math.max(0, prev - 1)); } catch (error) { notify({ error }); } @@ -199,74 +260,228 @@ const JobList = () => { } }; + // Compute stats from current data + const stats = useMemo(() => { + const activeJobs = backup.filter( + (j) => j.periodic_task_details?.enabled + ).length; + const pausedJobs = backup.length - activeJobs; + + // Filter to jobs that ran in the last 24 hours for rate/failure stats + const cutoff24h = Date.now() - 86400000; + const recentJobs = backup.filter( + (j) => + j.task_completion_time && new Date(j.task_completion_time) >= cutoff24h + ); + const failedJobs = recentJobs.filter((j) => + ["FAILED", "FAILURE", "FAILED PERMANENTLY"].includes(j.task_status) + ).length; + const successJobs = recentJobs.filter( + (j) => j.task_status === "SUCCESS" + ).length; + const successRate = + recentJobs.length > 0 + ? Math.round((successJobs / recentJobs.length) * 100) + : null; + + // Next upcoming run + const upcomingRuns = backup + .filter((j) => j.next_run_time && j.periodic_task_details?.enabled) + .sort((a, b) => new Date(a.next_run_time) - new Date(b.next_run_time)); + const nextRun = upcomingRuns.length > 0 ? upcomingRuns[0] : null; + let nextRunCountdown = null; + if (nextRun?.next_run_time) { + const diff = new Date(nextRun.next_run_time) - new Date(); + if (diff > 0) { + const mins = Math.floor(diff / 60000); + if (mins < 60) nextRunCountdown = `in ${mins}m`; + else if (mins < 1440) + nextRunCountdown = `in ${Math.floor(mins / 60)}h ${mins % 60}m`; + else nextRunCountdown = `in ${Math.floor(mins / 1440)}d`; + } + } + + return { + activeJobs, + pausedJobs, + failedJobs, + successRate, + nextRun, + nextRunCountdown, + }; + }, [backup]); + return ( -
-
- - - Jobs - +
+
+ {/* Header */} +
+
+ + Jobs + + + Scheduled data pipelines across all your projects. + +
+ +
- {isJobLimitReached && ( - navigate("/project/setting/subscriptions")} - > - Upgrade - + {isJobLimitReached && ( + navigate("/project/setting/subscriptions")} + > + Upgrade + + } + /> + )} + + {/* Stats Cards */} + + + + } + value={stats.activeJobs} + subtext={ + stats.pausedJobs > 0 && ( + {stats.pausedJobs} paused + ) } /> - )} + + + } + value={ + stats.successRate != null ? `${stats.successRate}%` : "— %" + } + valueColor={ + stats.successRate === 100 + ? token.colorSuccess + : stats.successRate > 0 + ? token.colorWarning + : undefined + } + /> + + + } + value={stats.failedJobs} + valueColor={stats.failedJobs > 0 ? token.colorError : undefined} + subtext={ + stats.failedJobs > 0 && ( + + Needs attention + + ) + } + /> + + + } + value={stats.nextRunCountdown || "—"} + subtext={ + stats.nextRun && ( + + {stats.nextRun.task_name} ·{" "} + {stats.nextRun.next_run_time + ? new Date( + stats.nextRun.next_run_time + ).toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + }) + : ""} + + ) + } + /> + + - {/* Filters */} - setOpenJobDeploy(true)} - onRefresh={() => fetchJobs({ page: currentPage, limit: pageSize })} - loading={tableLoading} - isJobLimitReached={isJobLimitReached} - /> + {/* Filters */} + setOpenJobDeploy(true)} + onRefresh={() => fetchJobs({ page: currentPage, limit: pageSize })} + loading={tableLoading} + isJobLimitReached={isJobLimitReached} + totalJobs={jobList.length} + /> - {/* Table */} - fetchJobs({ showLoader: false })} - /> - {jobList?.length > 0 && ( - - - `Showing ${range[0]} to ${range[1]} of ${Math.min( - totalCount, - 1000 - )} entries` - } - showSizeChanger - onChange={handlePagination} - /> - - )} - + {/* Table */} + fetchJobs({ showLoader: false })} + /> + + {jobList?.length > 0 && ( +
+ + `Showing ${range[0]}–${range[1]} of ${total} jobs` + } + showSizeChanger + onChange={handlePagination} + /> +
+ )}
{/* Job Deploy Modal */} @@ -287,7 +502,7 @@ const JobList = () => { okText="Delete" okButtonProps={{ danger: true }} > - Are you sure you want to delete this Job? + Are you sure you want to delete this Job?
); diff --git a/frontend/src/ide/scheduler/JobListFilters.jsx b/frontend/src/ide/scheduler/JobListFilters.jsx index 14eb530..a65a075 100644 --- a/frontend/src/ide/scheduler/JobListFilters.jsx +++ b/frontend/src/ide/scheduler/JobListFilters.jsx @@ -1,5 +1,5 @@ -import { Input, Select, Button, Space } from "antd"; -import { PlusOutlined, ReloadOutlined } from "@ant-design/icons"; +import { Input, Select, Button, Space, Card, Row, Col } from "antd"; +import { ReloadOutlined, SearchOutlined } from "@ant-design/icons"; import { memo } from "react"; import PropTypes from "prop-types"; @@ -15,56 +15,128 @@ const JobListFilters = memo( onRefresh, loading, isJobLimitReached = false, - }) => ( -
- - + totalJobs = 0, + }) => { + const activeFilterCount = [ + filters.env !== "all" ? filters.env : null, + filters.proj !== "all" ? filters.proj : null, + filters.status || null, + filters.lastRun || null, + filters.schedule || null, + searchQuery || null, + ].filter(Boolean).length; - setFilters({ ...filters, proj: value })} - className="job-deploy-header-field" - /> - - - - -
- ) + return ( + + + + } + onChange={onSearchChange} + value={searchQuery} + allowClear + /> + + + setFilters({ ...filters, lastRun: v || "" })} + options={[ + { label: "Last 24h", value: "24h" }, + { label: "Last 7 days", value: "7d" }, + { label: "Last 30 days", value: "30d" }, + ]} + /> + + + setFilters({ ...filters, schedule: v || "" })} + options={[ + { label: "Cron", value: "cron" }, + { label: "Interval", value: "interval" }, + ]} + /> + + + + + {totalJobs} job{totalJobs !== 1 ? "s" : ""} + + {activeFilterCount > 0 && ( + + )} + - - ), + sorter: (a, b) => + (a.task_name || "").localeCompare(b.task_name || ""), + render: (text, record) => { + const isFailed = [ + "FAILED", + "FAILED PERMANENTLY", + "FAILURE", + ].includes(record.task_status); + const isSuccess = record.task_status === "SUCCESS"; + const isRunning = ["RUNNING", "STARTED", "PENDING"].includes( + record.task_status + ); + const isPaused = !record.periodic_task_details?.enabled; + let statusIcon; + let statusClass; + let tooltipText; + if (isPaused) { + statusIcon = ; + statusClass = "paused"; + tooltipText = "Paused — will not run"; + } else if (isFailed) { + statusIcon = ; + statusClass = "failed"; + tooltipText = "Last run failed — needs attention"; + } else if (isSuccess) { + statusIcon = ; + statusClass = "success"; + tooltipText = "Healthy — last run succeeded"; + } else if (isRunning) { + statusIcon = ; + statusClass = "running"; + tooltipText = "Running"; + } else { + statusIcon = ; + statusClass = "paused"; + tooltipText = "Scheduled — has not run yet"; + } + return ( +
+ +
+ {statusIcon} +
+
+
+ + {record.description && ( +
+ + {record.description} + +
+ )} +
+
+ ); + }, }, { title: "Project", dataIndex: "project", key: "project", - render: (project) => ( - {project?.name} - ), + render: (project) => {project?.name}, }, { title: "Environment", dataIndex: "environment", key: "environment", - render: (environment) => ( - - }> - {environment?.name} - - - ), + render: (env) => + env ? ( + + ) : ( + + ), }, { title: "Schedule", key: "schedule", - render: (_, record) => { - const scheduleText = getTooltipText( - record.periodic_task_details?.[record.task_type] ?? {}, - record.task_type - ); - return ( - - }> - {record.task_type === "interval" ? "Interval" : "Cron"} - - - {scheduleText} - - - ); - }, + render: (_, record) => ( + + ), }, { - title: "Last Run Status", - key: "last_run_status", + title: "Last Run", + key: "last_run", + sorter: (a, b) => + new Date(a.task_completion_time || 0) - + new Date(b.task_completion_time || 0), render: (_, record) => { - if (!record.task_status) { - return ; - } + if (!record.task_status) return ; const isFailed = [ "FAILED", "FAILED PERMANENTLY", "FAILURE", ].includes(record.task_status); + const isSuccess = record.task_status === "SUCCESS"; + const isRunning = ["RUNNING", "STARTED", "PENDING"].includes( + record.task_status + ); + + // Compute duration if both times available + let duration = null; + if (record.task_run_time && record.task_completion_time) { + const ms = + new Date(record.task_completion_time) - + new Date(record.task_run_time); + if (ms > 0) duration = formatDurationMs(ms); + } + return ( - + + ) : isFailed ? ( + + ) : isRunning ? ( + + ) : null + } color={ - isFailed - ? "red" - : record.task_status === "SUCCESS" - ? "green" + isSuccess + ? "success" + : isFailed + ? "error" + : isRunning + ? "processing" : "default" } > @@ -215,73 +336,139 @@ const JobListTable = memo( ? "FAILED" : record.task_status} - + {record.task_completion_time && ( + + + {formatDateTime(record.task_completion_time)} + + + )} + + {record.task_completion_time + ? getRelativeTime(record.task_completion_time) + : ""} + {duration ? ` · ${duration}` : ""} + +
); }, }, - { - title: "Last Run", - dataIndex: "task_completion_time", - key: "last_run", - render: renderDateCell, - }, { title: "Next Run", dataIndex: "next_run_time", key: "next_run", - render: renderDateCell, + sorter: (a, b) => + new Date(a.next_run_time || 0) - new Date(b.next_run_time || 0), + render: (text, record) => { + if (!text) { + if (!record.periodic_task_details?.enabled) { + return ( + + + Paused + + ); + } + return ; + } + // Compute "in Xm" countdown + const diff = new Date(text) - new Date(); + let countdown = null; + if (diff > 0) { + const mins = Math.floor(diff / 60000); + if (mins < 60) countdown = `in ${mins}m`; + else if (mins < 1440) + countdown = `in ${Math.floor(mins / 60)}h ${mins % 60}m`; + else countdown = `in ${Math.floor(mins / 1440)}d`; + } + return ( + + {formatDateTime(text)} + {countdown && ( + + {countdown} + + )} + + ); + }, }, { title: "Status", key: "status", - render: (_, record) => ( - - { - handleSwitchSchedular(record, checked); - }} - /> - - - ), + render: (_, record) => { + const enabled = record.periodic_task_details?.enabled; + return ( + + handleSwitchSchedular(record, checked)} + size="small" + /> + + {enabled ? "Enabled" : "Paused"} + + + ); + }, }, { title: "Actions", key: "actions", + width: 140, render: (_, record) => ( - - + +