From d4870ad8ea27ac37fc841fd9f1a3284bc1588d13 Mon Sep 17 00:00:00 2001 From: wicky <130177258+wicky-zipstack@users.noreply.github.com> Date: Thu, 23 Apr 2026 17:51:19 +0530 Subject: [PATCH 1/8] =?UTF-8?q?feat:=20redesign=20Jobs=20List=20page=20?= =?UTF-8?q?=E2=80=94=20stats=20cards,=20badges,=20filters,=20actions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stats cards: - Active Jobs (with paused count) - Success Rate (24H) — computed client-side - Failed Runs (24H) with attention indicator - Next Run (countdown + job name) Table columns redesigned: - Job: icon + name (link) + description - Environment: PROD/STG/DEV styled badges with env name - Schedule: CRON/INTERVAL tags with expression + description - Last Run: status tag + date + relative time + duration - Next Run: date + "in Xm" countdown badge, "Paused" when disabled - Status: toggle switch + label - Actions: Run now, History, Edit, Delete icons Filters redesigned: - Inside bordered Card (matches Run History) - Search, Status, Last run, Environment, Schedule dropdowns - Job count + Clear filters link + Refresh Layout: matches Run History page width pattern --- frontend/src/ide/scheduler/JobDeploy.css | 19 + frontend/src/ide/scheduler/JobList.jsx | 387 ++++++++------- frontend/src/ide/scheduler/JobListFilters.jsx | 161 ++++-- frontend/src/ide/scheduler/JobListTable.jsx | 466 +++++++++--------- 4 files changed, 589 insertions(+), 444 deletions(-) diff --git a/frontend/src/ide/scheduler/JobDeploy.css b/frontend/src/ide/scheduler/JobDeploy.css index 7d2eeb3d..edd781af 100644 --- a/frontend/src/ide/scheduler/JobDeploy.css +++ b/frontend/src/ide/scheduler/JobDeploy.css @@ -2,6 +2,25 @@ font-weight: bold; } +/* ── Job List Table ── */ +.jl-job-name { + display: flex; + align-items: center; + gap: 8px; +} + +.jl-job-icon { + width: 28px; + height: 28px; + border-radius: 6px; + background: var(--bg-color-3, rgba(0, 0, 0, 0.04)); + color: var(--primary-color, #1677ff); + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + .job-deploy-header-field { width: 200px; } diff --git a/frontend/src/ide/scheduler/JobList.jsx b/frontend/src/ide/scheduler/JobList.jsx index 7bca882e..5bbedefa 100644 --- a/frontend/src/ide/scheduler/JobList.jsx +++ b/frontend/src/ide/scheduler/JobList.jsx @@ -1,5 +1,22 @@ import { useEffect, useState, useCallback, useMemo } from "react"; -import { Alert, Button, Space, Typography, Modal, Pagination } from "antd"; +import { + Alert, + Button, + Space, + Typography, + Modal, + Pagination, + Card, + Row, + Col, + theme, +} from "antd"; +import { + CheckCircleFilled, + CloseCircleFilled, + ThunderboltOutlined, + PlusOutlined, +} from "@ant-design/icons"; import debounce from "lodash/debounce"; import { useNavigate, useSearchParams } from "react-router-dom"; @@ -13,6 +30,8 @@ import { JobDeploy } from "./JobDeploy.jsx"; import "./JobDeploy.css"; +const { Text, Title } = Typography; + let useSubscriptionDetailsStoreSafe; try { useSubscriptionDetailsStoreSafe = @@ -21,14 +40,37 @@ try { useSubscriptionDetailsStoreSafe = null; } +/* ── Duration formatter ── */ +const formatDurationMs = (ms) => { + if (!ms && ms !== 0) return "—"; + 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`; +}; + +/* ── 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 [delTaskDetail, setDelTaskDetail] = useState({ - projectId: "", - taskId: "", - }); + const { token } = theme.useToken(); + const [delTaskDetail, setDelTaskDetail] = useState({ projectId: "", taskId: "" }); const [jobList, setJobList] = useState([]); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const [isJobListModified, setIsJobListModified] = useState(false); @@ -41,32 +83,15 @@ 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 { - currentPage, - pageSize, - totalCount, - setTotalCount, - setCurrentPage, - setPageSize, - } = usePagination(); + const [filters, setFilters] = useState({ proj: "all", env: "all", status: "", lastRun: "", schedule: "" }); + const { currentPage, pageSize, totalCount, setTotalCount, setCurrentPage, setPageSize } = usePagination(); const canWrite = checkPermission("JOB_DEPLOYMENT", "can_write"); - const usage = - typeof useSubscriptionDetailsStoreSafe === "function" - ? useSubscriptionDetailsStoreSafe((s) => s.usage) - : null; - const isJobLimitReached = - usage?.jobs && usage.jobs.used >= usage.jobs.allowed; + const usage = typeof useSubscriptionDetailsStoreSafe === "function" ? useSubscriptionDetailsStoreSafe((s) => s.usage) : null; + const isJobLimitReached = usage?.jobs && usage.jobs.used >= usage.jobs.allowed; - const fetchJobs = async ({ - page = currentPage, - limit = pageSize, - showLoader = true, - } = {}) => { - if (showLoader) { - setTableLoading(true); - } + const fetchJobs = async ({ page = currentPage, limit = pageSize, showLoader = true } = {}) => { + if (showLoader) setTableLoading(true); try { const tasks = await listPeriodicTasks(page, limit); const { page_items, total_items, current_page } = tasks.data; @@ -78,91 +103,58 @@ const JobList = () => { } catch (error) { notify({ error }); } finally { - if (showLoader) { - setTableLoading(false); - } + if (showLoader) setTableLoading(false); } }; const fetchProjects = useCallback(async () => { try { const data = await getProjects(); - setProjects( - data.map((el) => ({ - label: el.project_name, - value: el.project_name, - })) - ); + setProjects(data.map((el) => ({ label: el.project_name, value: el.project_name }))); } catch (error) { notify({ error }); } }, [getProjects]); - useEffect(() => { - fetchJobs({ showLoader: true }); - fetchProjects(); - }, []); + useEffect(() => { fetchJobs({ showLoader: true }); fetchProjects(); }, []); + useEffect(() => { if (!isJobListModified) return; fetchJobs(); setIsJobListModified(false); }, [isJobListModified]); - 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().includes(term) || project?.name?.toLowerCase().includes(term) + )); + }, 300), [backup]); - 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 - ); - }, []); + useEffect(() => () => runSearch.cancel(), [runSearch]); + const onSearchChange = (e) => { const v = e.target.value; setSearchQuery(v); runSearch(v); }; + // 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 } = 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 (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); - setSelectedJobId(id); - }, []); + const handleRowClick = useCallback((id) => { setOpenJobDeploy(true); setSelectedJobId(id); }, []); - useEffect(() => { - if (!openJobDeploy) { - setSelectedJobId(null); - setPrefillModel(null); - setPrefillProject(null); - } - }, [openJobDeploy]); + useEffect(() => { if (!openJobDeploy) { setSelectedJobId(null); setPrefillModel(null); setPrefillProject(null); } }, [openJobDeploy]); useEffect(() => { if (searchParams.get("create") === "1") { @@ -177,18 +169,9 @@ const JobList = () => { try { await deleteTask(delTaskDetail.projectId, delTaskDetail.taskId); setIsDeleteModalOpen(false); - notify({ - type: "success", - message: `Job Deleted Successfully`, - }); - setJobList( - jobList.filter( - (el) => el.periodic_task_details.id !== delTaskDetail.taskId - ) - ); - } catch (error) { - notify({ error }); - } + notify({ type: "success", message: "Job deleted successfully" }); + setJobList(jobList.filter((el) => el.periodic_task_details.id !== delTaskDetail.taskId)); + } catch (error) { notify({ error }); } }; const handlePagination = (newPage, newPageSize) => { @@ -199,74 +182,138 @@ 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; + const failedJobs = backup.filter((j) => ["FAILED", "FAILURE", "FAILED PERMANENTLY"].includes(j.task_status)).length; + const successJobs = backup.filter((j) => j.task_status === "SUCCESS").length; + const successRate = backup.length > 0 ? Math.round((successJobs / backup.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 +334,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 14eb530e..09f64963 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, Badge, Card, Row, Col } from "antd"; +import { PlusOutlined, ReloadOutlined, SearchOutlined } from "@ant-design/icons"; import { memo } from "react"; import PropTypes from "prop-types"; @@ -15,56 +15,116 @@ 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 && ( + + )} + - - ), - }, - { - title: "Project", - dataIndex: "project", - key: "project", - render: (project) => ( - {project?.name} - ), - }, - { - title: "Environment", - dataIndex: "environment", - key: "environment", - render: (environment) => ( - - }> - {environment?.name} + {record.description && ( +
{record.description}
+ )} +
+
+ ), + }, + { + title: "Project", + dataIndex: "project", + key: "project", + render: (project) => {project?.name}, + }, + { + title: "Environment", + dataIndex: "environment", + key: "environment", + render: (env) => env ? : , + }, + { + title: "Schedule", + key: "schedule", + render: (_, record) => ( + + ), + }, + { + 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 ; + 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={isSuccess ? "success" : isFailed ? "error" : isRunning ? "processing" : "default"} + > + {record.task_status === "FAILURE" ? "FAILED" : record.task_status} - - ), - }, - { - 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} - - - ); - }, + {record.task_completion_time && ( + + {formatDateTime(record.task_completion_time)} + + )} + + {record.task_completion_time ? getRelativeTime(record.task_completion_time) : ""} + {duration ? ` · ${duration}` : ""} + + + ); }, - { - title: "Last Run Status", - key: "last_run_status", - render: (_, record) => { - if (!record.task_status) { - return ; + }, + { + title: "Next Run", + dataIndex: "next_run_time", + key: "next_run", + 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; } - const isFailed = [ - "FAILED", - "FAILED PERMANENTLY", - "FAILURE", - ].includes(record.task_status); - return ( - - - {record.task_status === "FAILURE" - ? "FAILED" - : record.task_status} + 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: "Last Run", - dataIndex: "task_completion_time", - key: "last_run", - render: renderDateCell, - }, - { - title: "Next Run", - dataIndex: "next_run_time", - key: "next_run", - render: renderDateCell, + )} + + ); }, - { - title: "Status", - key: "status", - render: (_, record) => ( - + }, + { + title: "Status", + key: "status", + render: (_, record) => { + const enabled = record.periodic_task_details?.enabled; + return ( + { - handleSwitchSchedular(record, checked); - }} + checked={enabled} + onChange={(checked) => handleSwitchSchedular(record, checked)} + size="small" /> - + {enabled ? "Enabled" : "Paused"} - ), + ); }, - { - title: "Actions", - key: "actions", - render: (_, record) => ( - - -