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.
+
+
+
}
+ onClick={() => setOpenJobDeploy(true)}
+ disabled={!canWrite || isJobLimitReached}
+ >
+ Create Job
+
+
- {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, env: value })}
- options={[
- { label: "All", value: "all" },
- { label: "STAGING", value: "STG" },
- { label: "DEV", value: "DEV" },
- { label: "PROD", value: "PROD" },
- ]}
- className="job-deploy-header-field"
- />
+ const handleClearFilters = () => {
+ setFilters({
+ env: "all",
+ proj: "all",
+ status: "",
+ lastRun: "",
+ schedule: "",
+ });
+ if (onSearchChange) onSearchChange({ target: { value: "" } });
+ };
- setFilters({ ...filters, proj: value })}
- className="job-deploy-header-field"
- />
-
-
-
- }
- onClick={onCreateJob}
- disabled={!canWrite || isJobLimitReached}
- className="primary_button_style"
- >
- Create Job
-
- }
- onClick={onRefresh}
- disabled={loading}
- />
-
-
- )
+ return (
+
+
+
+ }
+ onChange={onSearchChange}
+ value={searchQuery}
+ allowClear
+ />
+
+
+ setFilters({ ...filters, status: v || "" })}
+ options={[
+ { label: "Success", value: "SUCCESS" },
+ { label: "Failed", value: "FAILED" },
+ { label: "Running", value: "RUNNING" },
+ ]}
+ />
+
+
+ setFilters({ ...filters, lastRun: v || "" })}
+ options={[
+ { label: "Last 24h", value: "24h" },
+ { label: "Last 7 days", value: "7d" },
+ { label: "Last 30 days", value: "30d" },
+ ]}
+ />
+
+
+ setFilters({ ...filters, env: v || "all" })}
+ options={[
+ { label: "Production", value: "PROD" },
+ { label: "Staging", value: "STG" },
+ { label: "Development", value: "DEV" },
+ ]}
+ />
+
+
+ setFilters({ ...filters, schedule: v || "" })}
+ options={[
+ { label: "Cron", value: "cron" },
+ { label: "Interval", value: "interval" },
+ ]}
+ />
+
+
+
+
+ {totalJobs} job{totalJobs !== 1 ? "s" : ""}
+
+ {activeFilterCount > 0 && (
+
+ Clear filters
+
+ )}
+ }
+ onClick={onRefresh}
+ disabled={loading}
+ />
+
+
+
+
+ );
+ }
);
JobListFilters.propTypes = {
@@ -78,6 +150,7 @@ JobListFilters.propTypes = {
onRefresh: PropTypes.func,
loading: PropTypes.bool,
isJobLimitReached: PropTypes.bool,
+ totalJobs: PropTypes.number,
};
JobListFilters.displayName = "JobListFilters";
diff --git a/frontend/src/ide/scheduler/JobListTable.jsx b/frontend/src/ide/scheduler/JobListTable.jsx
index 694e0e2..973bef4 100644
--- a/frontend/src/ide/scheduler/JobListTable.jsx
+++ b/frontend/src/ide/scheduler/JobListTable.jsx
@@ -1,3 +1,4 @@
+/* eslint-disable react/prop-types */
import { memo, useMemo, useState } from "react";
import {
Table,
@@ -8,15 +9,23 @@ import {
Button,
Switch,
Empty,
+ theme,
} from "antd";
import {
- DatabaseOutlined,
- CalendarOutlined,
EditOutlined,
DeleteOutlined,
PlayCircleOutlined,
LoadingOutlined,
HistoryOutlined,
+ CheckCircleFilled,
+ CloseCircleFilled,
+ SyncOutlined,
+ ClockCircleOutlined,
+ PauseCircleOutlined,
+ FireFilled,
+ ExperimentOutlined,
+ CodeOutlined,
+ FieldTimeOutlined,
} from "@ant-design/icons";
import PropTypes from "prop-types";
import { useNavigate } from "react-router-dom";
@@ -29,6 +38,85 @@ import {
} from "../../common/helpers";
import { useNotificationService } from "../../service/notification-service";
+const { Text } = Typography;
+
+/* ── Duration formatter ── */
+const formatDurationMs = (ms) => {
+ if (!ms && ms !== 0) return null;
+ if (ms < 1000) return `${ms}ms`;
+ if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
+ const m = Math.floor(ms / 60000);
+ const s = ((ms % 60000) / 1000).toFixed(0);
+ return `${m}m ${s}s`;
+};
+
+/* ── Environment badge ── */
+const EnvironmentBadge = ({ type, name }) => {
+ const config = {
+ PROD: { color: "error", label: "PROD", icon: },
+ STG: { color: "warning", label: "STG", icon: },
+ DEV: { color: "blue", label: "DEV", icon: },
+ };
+ const c = config[type] || config.DEV;
+ return (
+
+
+ {c.label}
+
+
+ {name}
+
+
+ );
+};
+
+/* ── Schedule badge ── */
+const ScheduleBadge = ({ type, details }) => {
+ const isCron = type === "cron";
+ const expression = isCron ? details?.cron?.cron_expression : null;
+ let description = "";
+ try {
+ if (isCron && expression) {
+ description =
+ getTooltipText({ cron_expression: expression }, type) + " (UTC)";
+ } else if (!isCron && details?.interval) {
+ description = getTooltipText(details.interval, type);
+ }
+ } catch {
+ description = expression || "";
+ }
+ return (
+
+
+ : }
+ style={{
+ fontWeight: 700,
+ fontSize: 10,
+ letterSpacing: 0.4,
+ margin: 0,
+ }}
+ >
+ {isCron ? "CRON" : "INTERVAL"}
+
+ {expression && (
+
+ {expression}
+
+ )}
+
+
+ {description}
+
+
+ );
+};
+
const JobListTable = memo(
({
data,
@@ -42,27 +130,11 @@ const JobListTable = memo(
const [loading, setLoading] = useState({});
const { notify } = useNotificationService();
const navigate = useNavigate();
+ const { token } = theme.useToken();
const goToRunHistory = (userTaskId) =>
navigate(`/project/job/history?task=${userTaskId}`);
- const renderDateCell = (text) => {
- if (!text) {
- return (
- Not started yet.
- );
- }
- return (
-
-
- {formatDateTime(text)}
-
- {getRelativeTime(text)}
-
-
-
- );
- };
const handleSwitchSchedular = async (item, checked) => {
try {
const {
@@ -83,10 +155,7 @@ const JobListTable = memo(
environment: environment.id,
enabled: checked,
});
- if (res.status) {
- // Re-fetch the job list to get updated data from server
- if (onToggleSuccess) onToggleSuccess();
- }
+ if (res.status && onToggleSuccess) onToggleSuccess();
} catch (error) {
notify({ error });
}
@@ -104,10 +173,7 @@ const JobListTable = memo(
setLoading((prev) => ({ ...prev, [taskId]: true }));
try {
await runTask(projectId, taskId);
- notify({
- type: "success",
- message: "Job Scheduled Successfully",
- });
+ notify({ type: "success", message: "Job triggered successfully" });
} catch (error) {
notify({ error });
} finally {
@@ -125,89 +191,144 @@ const JobListTable = memo(
title: "Job",
dataIndex: "task_name",
key: "task_name",
- render: (text, record) => (
-
- }
- onClick={() => goToRunHistory(record.user_task_id)}
- >
- {text}
-
-
- ),
+ 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}
+
+
+
+
goToRunHistory(record.user_task_id)}
+ >
+ {text}
+
+ {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) => (
-
-
+
+
+
) : (
-
+
)
}
onClick={() =>
- handleRun(record.project.id, record.user_task_id)
+ handleRun(record.project?.id, record.user_task_id)
}
+ disabled={loading[record.user_task_id]}
/>
-
+
}
+ onClick={() => goToRunHistory(record.user_task_id)}
+ />
+
+
+ }
onClick={() => onRowClick(record.user_task_id)}
/>
-
+
}
onClick={() => handleDelete(record)}
@@ -291,14 +478,17 @@ const JobListTable = memo(
),
},
],
- [getTooltipText, onRowClick, loading]
+ [token, loading, onRowClick]
);
+
return (
),
}}
- bordered
- className="job-deploy-table"
- pagination={{ pageSize: 10 }}
/>
);
}
);
+
+JobListTable.displayName = "JobListTable";
+
JobListTable.propTypes = {
data: PropTypes.array,
onRowClick: PropTypes.func,
@@ -322,5 +512,5 @@ JobListTable.propTypes = {
tableLoading: PropTypes.bool,
onToggleSuccess: PropTypes.func,
};
-JobListTable.displayName = "JobListTable";
+
export { JobListTable };