From 528400eaf35c1cccb41b9eaa7b76a0faaf512d06 Mon Sep 17 00:00:00 2001 From: Nicole Rappe Date: Thu, 16 Oct 2025 04:12:19 -0600 Subject: [PATCH 1/3] Refactor scheduled jobs list to Quartz AG Grid --- .../src/Scheduling/Scheduled_Jobs_List.jsx | 947 ++++++++++++------ 1 file changed, 613 insertions(+), 334 deletions(-) diff --git a/Data/Server/WebUI/src/Scheduling/Scheduled_Jobs_List.jsx b/Data/Server/WebUI/src/Scheduling/Scheduled_Jobs_List.jsx index 9aa9810..e7c18b6 100644 --- a/Data/Server/WebUI/src/Scheduling/Scheduled_Jobs_List.jsx +++ b/Data/Server/WebUI/src/Scheduling/Scheduled_Jobs_List.jsx @@ -1,397 +1,676 @@ ////////// PROJECT FILE SEPARATION LINE ////////// CODE AFTER THIS LINE ARE FROM: /Data/WebUI/src/Scheduled_Jobs_List.jsx -import React, { useState, useMemo } from "react"; +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState +} from "react"; import { Paper, Box, Typography, Button, - Table, - TableBody, - TableCell, - TableHead, - TableRow, - TableSortLabel, Switch, Dialog, DialogTitle, - DialogContent, DialogActions, - Checkbox, - Popover, - TextField, - IconButton + CircularProgress } from "@mui/material"; -import FilterListIcon from "@mui/icons-material/FilterList"; +import { AgGridReact } from "ag-grid-react"; +import { ModuleRegistry, AllCommunityModule, themeQuartz } from "ag-grid-community"; + +ModuleRegistry.registerModules([AllCommunityModule]); + +const myTheme = themeQuartz.withParams({ + accentColor: "#FFA6FF", + backgroundColor: "#1f2836", + browserColorScheme: "dark", + chromeBackgroundColor: { + ref: "foregroundColor", + mix: 0.07, + onto: "backgroundColor" + }, + fontFamily: { + googleFont: "IBM Plex Sans" + }, + foregroundColor: "#FFF", + headerFontSize: 14 +}); + +const themeClassName = myTheme.themeName || "ag-theme-quartz"; +const gridFontFamily = '"IBM Plex Sans", "Helvetica Neue", Arial, sans-serif'; +const iconFontFamily = '"Quartz Regular"'; + +function ResultsBar({ counts }) { + const total = Math.max(1, Number(counts?.total_targets || 0)); + const sections = [ + { key: "success", color: "#00d18c" }, + { key: "running", color: "#58a6ff" }, + { key: "failed", color: "#ff4f4f" }, + { key: "timed_out", color: "#b36ae2" }, + { key: "expired", color: "#777777" }, + { key: "pending", color: "#999999" } + ]; + const labelFor = (key) => + key === "pending" + ? "Scheduled" + : key + .replace(/_/g, " ") + .replace(/^./, (c) => c.toUpperCase()); + + const hasNonPending = sections + .filter((section) => section.key !== "pending") + .some((section) => Number(counts?.[section.key] || 0) > 0); + + return ( + + + {sections.map((section) => { + const value = Number(counts?.[section.key] || 0); + if (!value) return null; + const width = `${Math.round((value / total) * 100)}%`; + return ( + + ); + })} + + + {(() => { + if (!hasNonPending && Number(counts?.pending || 0) > 0) { + return Scheduled; + } + return sections + .filter((section) => Number(counts?.[section.key] || 0) > 0) + .map((section) => ( + + + {counts?.[section.key]} {labelFor(section.key)} + + )); + })()} + + + ); +} export default function ScheduledJobsList({ onCreateJob, onEditJob, refreshToken }) { const [rows, setRows] = useState([]); - const [orderBy, setOrderBy] = useState("name"); - const [order, setOrder] = useState("asc"); - const [selected, setSelected] = useState(new Set()); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(""); const [bulkDeleteOpen, setBulkDeleteOpen] = useState(false); - const [filters, setFilters] = useState({}); // {name, occurrence, lastRun, nextRun} - const [filterAnchor, setFilterAnchor] = useState(null); // { id, anchorEl } - const openFilter = (id) => (e) => setFilterAnchor({ id, anchorEl: e.currentTarget }); - const closeFilter = () => setFilterAnchor(null); - const onFilterChange = (id) => (e) => setFilters((prev) => ({ ...prev, [id]: e.target.value })); + const [selectedIds, setSelectedIds] = useState(() => new Set()); + const gridApiRef = useRef(null); - const loadJobs = async () => { - try { - const resp = await fetch('/api/scheduled_jobs'); - const data = await resp.json(); - if (!resp.ok) throw new Error(data.error || `HTTP ${resp.status}`); - const pretty = (st) => { - const s = String(st || '').toLowerCase(); - const map = { - 'immediately': 'Immediately', - 'once': 'Once', - 'every_5_minutes': 'Every 5 Minutes', - 'every_10_minutes': 'Every 10 Minutes', - 'every_15_minutes': 'Every 15 Minutes', - 'every_30_minutes': 'Every 30 Minutes', - 'every_hour': 'Every Hour', - 'daily': 'Daily', - 'weekly': 'Weekly', - 'monthly': 'Monthly', - 'yearly': 'Yearly', + const loadJobs = useCallback( + async ({ showLoading = false } = {}) => { + if (showLoading) { + setLoading(true); + setError(""); + } + try { + const resp = await fetch("/api/scheduled_jobs"); + const data = await resp.json().catch(() => ({})); + if (!resp.ok) { + throw new Error(data?.error || `HTTP ${resp.status}`); + } + const pretty = (st) => { + const s = String(st || "").toLowerCase(); + const map = { + immediately: "Immediately", + once: "Once", + every_5_minutes: "Every 5 Minutes", + every_10_minutes: "Every 10 Minutes", + every_15_minutes: "Every 15 Minutes", + every_30_minutes: "Every 30 Minutes", + every_hour: "Every Hour", + daily: "Daily", + weekly: "Weekly", + monthly: "Monthly", + yearly: "Yearly" + }; + if (map[s]) return map[s]; + try { + return s.replace(/_/g, " ").replace(/^./, (c) => c.toUpperCase()); + } catch { + return String(st || ""); + } }; - if (map[s]) return map[s]; - try { - return s.replace(/_/g, ' ').replace(/^./, c => c.toUpperCase()); - } catch { return String(st || ''); } - }; - const rows = (data.jobs || []).map((j) => { - const compName = (Array.isArray(j.components) && j.components[0]?.name) || "Demonstration Component"; - const targetText = Array.isArray(j.targets) ? `${j.targets.length} device${j.targets.length!==1?'s':''}` : ''; - const occurrence = pretty(j.schedule_type || 'immediately'); const fmt = (ts) => { - if (!ts) return ''; + if (!ts) return ""; try { const d = new Date(Number(ts) * 1000); - return d.toLocaleString(undefined, { year:'numeric', month:'2-digit', day:'2-digit', hour:'numeric', minute:'2-digit' }); - } catch { return ''; } + if (Number.isNaN(d?.getTime())) return ""; + return d.toLocaleString(undefined, { + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "numeric", + minute: "2-digit" + }); + } catch { + return ""; + } }; - const result = j.last_status || (j.next_run_ts ? 'Scheduled' : ''); - return { - id: j.id, - name: j.name, - scriptWorkflow: compName, - target: targetText, - occurrence, - lastRun: fmt(j.last_run_ts), - nextRun: fmt(j.next_run_ts || j.start_ts), - result, - resultsCounts: j.result_counts || { pending: (Array.isArray(j.targets)?j.targets.length:0) }, - enabled: !!j.enabled, - raw: j - }; - }); - setRows(rows); - } catch (e) { - console.warn('Failed to load jobs', e); - setRows([]); - } - }; - - // Initial load and polling each 5 seconds for live status updates - React.useEffect(() => { - let timer; - (async () => { try { await loadJobs(); } catch {} })(); - timer = setInterval(loadJobs, 5000); - return () => { if (timer) clearInterval(timer); }; - }, [refreshToken]); - - const handleSort = (col) => { - if (orderBy === col) setOrder(order === "asc" ? "desc" : "asc"); - else { - setOrderBy(col); - setOrder("asc"); - } - }; - - const filtered = useMemo(() => { - const f = filters || {}; - const match = (val, q) => String(val || "").toLowerCase().includes(String(q || "").toLowerCase()); - return rows.filter((r) => ( - (!f.name || match(r.name, f.name)) && - (!f.occurrence || match(r.occurrence, f.occurrence)) && - (!f.lastRun || match(r.lastRun, f.lastRun)) && - (!f.nextRun || match(r.nextRun, f.nextRun)) - )); - }, [rows, filters]); - - const sorted = useMemo(() => { - const dir = order === "asc" ? 1 : -1; - return [...filtered].sort((a, b) => { - const A = a[orderBy] || ""; - const B = b[orderBy] || ""; - return String(A).localeCompare(String(B)) * dir; - }); - }, [filtered, orderBy, order]); - - // Selection helpers - const anySelected = selected.size > 0; - const allSelected = useMemo(() => (sorted.length > 0 && sorted.every(r => selected.has(r.id))), [sorted, selected]); - const toggleSelect = (id, checked) => { - setSelected((prev) => { - const next = new Set(prev); - if (checked) next.add(id); else next.delete(id); - return next; - }); - }; - const toggleSelectAll = (checked) => { - if (checked) { - setSelected(new Set(sorted.map(r => r.id))); - } else { - setSelected(new Set()); - } - }; - - const resultColor = (r) => { - if (r === 'Success') return '#00d18c'; - if (r === 'Running') return '#58a6ff'; - if (r === 'Scheduled') return '#999999'; - if (r === 'Expired') return '#777777'; - if (r === 'Timed Out') return '#b36ae2'; - if (r === 'Warning') return '#ff8c00'; - if (r === 'Failed') return '#ff4f4f'; - return '#aaaaaa'; - }; - - const ResultsBar = ({ counts }) => { - const total = Math.max(1, Number(counts?.total_targets || 0)); - const seg = (n) => `${Math.round(((n||0)/total)*100)}%`; - const styleSeg = (bg, w) => ({ display: 'block', height: 8, background: bg, width: w }); - const s = counts || {}; - const sections = [ - { key: 'success', color: '#00d18c' }, - { key: 'running', color: '#58a6ff' }, - { key: 'failed', color: '#ff4f4f' }, - { key: 'timed_out', color: '#b36ae2' }, - { key: 'expired', color: '#777777' }, - { key: 'pending', color: '#999999' } - ]; - return ( -
-
- {sections.map(({key,color}) => (s[key] ? : null))} -
-
- {(() => { - const nonPendingKeys = ['success','running','failed','timed_out','expired'].filter(k => s[k]); - if (nonPendingKeys.length === 0 && s['pending']) { - // Pending-only: show simple "Scheduled" label under the bar - return Scheduled; + const mappedRows = (data?.jobs || []).map((j) => { + const compName = (Array.isArray(j.components) && j.components[0]?.name) || "Demonstration Component"; + const targetText = Array.isArray(j.targets) + ? `${j.targets.length} device${j.targets.length !== 1 ? "s" : ""}` + : ""; + const occurrence = pretty(j.schedule_type || "immediately"); + const resultsCounts = { + total_targets: Array.isArray(j.targets) ? j.targets.length : 0, + pending: Array.isArray(j.targets) ? j.targets.length : 0, + ...(j.result_counts || {}) + }; + if (resultsCounts && resultsCounts.total_targets == null) { + resultsCounts.total_targets = Array.isArray(j.targets) ? j.targets.length : 0; + } + return { + id: j.id, + name: j.name, + scriptWorkflow: compName, + target: targetText, + occurrence, + lastRun: fmt(j.last_run_ts), + nextRun: fmt(j.next_run_ts || j.start_ts), + result: j.last_status || (j.next_run_ts ? "Scheduled" : ""), + resultsCounts, + enabled: Boolean(j.enabled), + raw: j + }; + }); + setRows(mappedRows); + setError(""); + setSelectedIds((prev) => { + if (!prev.size) return prev; + const valid = new Set( + mappedRows.map((row, index) => row.id ?? row.name ?? String(index)) + ); + let changed = false; + const next = new Set(); + prev.forEach((value) => { + if (valid.has(value)) { + next.add(value); + } else { + changed = true; } - return ( - <> - {['success','running','failed','timed_out','expired','pending'] - .filter(k => s[k]) - .map((k) => ( - - x.key===k).color, marginRight: 6 }} /> - {s[k]} {k === 'pending' ? 'Scheduled' : k.replace('_',' ').replace(/^./, c=>c.toUpperCase())} - - ))} - - ); - })()} -
-
+ }); + return changed ? next : prev; + }); + } catch (err) { + setRows([]); + setSelectedIds(() => new Set()); + setError(String(err?.message || err || "Failed to load scheduled jobs")); + } finally { + if (showLoading) { + setLoading(false); + } + } + }, + [] + ); + + useEffect(() => { + let timer; + let isMounted = true; + (async () => { + if (!isMounted) return; + await loadJobs({ showLoading: true }); + })(); + timer = setInterval(() => { + loadJobs(); + }, 5000); + return () => { + isMounted = false; + if (timer) clearInterval(timer); + }; + }, [loadJobs, refreshToken]); + + const handleGridReady = useCallback((params) => { + gridApiRef.current = params.api; + }, []); + + useEffect(() => { + const api = gridApiRef.current; + if (!api) return; + if (loading) { + api.showLoadingOverlay(); + } else if (!rows.length) { + api.showNoRowsOverlay(); + } else { + api.hideOverlay(); + } + }, [loading, rows]); + + useEffect(() => { + const api = gridApiRef.current; + if (!api) return; + api.forEachNode((node) => { + const shouldSelect = selectedIds.has(node.id); + if (node.isSelected() !== shouldSelect) { + node.setSelected(shouldSelect); + } + }); + }, [rows, selectedIds]); + + const anySelected = selectedIds.size > 0; + + const handleSelectionChanged = useCallback(() => { + const api = gridApiRef.current; + if (!api) return; + const selectedNodes = api.getSelectedNodes(); + const next = new Set(); + selectedNodes.forEach((node) => { + if (node?.id != null) { + next.add(String(node.id)); + } + }); + setSelectedIds(next); + }, []); + + const getRowId = useCallback((params) => { + return ( + params?.data?.id ?? + params?.data?.name ?? + String(params?.rowIndex ?? "") ); - }; + }, []); + + const nameCellRenderer = useCallback( + (params) => { + const row = params.data; + if (!row) return null; + const handleClick = (event) => { + event.preventDefault(); + event.stopPropagation(); + if (typeof onEditJob === "function") { + onEditJob(row.raw); + } + }; + return ( + + ); + }, + [onEditJob] + ); + + const resultsCellRenderer = useCallback((params) => { + return ; + }, []); + + const enabledCellRenderer = useCallback( + (params) => { + const row = params.data; + if (!row) return null; + const handleToggle = async (event) => { + event.stopPropagation(); + const nextEnabled = event.target.checked; + try { + await fetch(`/api/scheduled_jobs/${row.id}/toggle`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ enabled: nextEnabled }) + }); + } catch { + // ignore network errors for toggle + } + setRows((prev) => + prev.map((job) => { + if ((job.id ?? job.name) === (row.id ?? row.name)) { + const updatedRaw = { ...(job.raw || {}), enabled: nextEnabled }; + return { ...job, enabled: nextEnabled, raw: updatedRaw }; + } + return job; + }) + ); + }; + return ( + event.stopPropagation()} + sx={{ + "& .MuiSwitch-switchBase.Mui-checked": { + color: "#58a6ff" + }, + "& .MuiSwitch-switchBase.Mui-checked + .MuiSwitch-track": { + bgcolor: "#58a6ff" + } + }} + /> + ); + }, + [] + ); + + const columnDefs = useMemo( + () => [ + { + headerName: "", + field: "__checkbox__", + checkboxSelection: true, + headerCheckboxSelection: true, + maxWidth: 60, + minWidth: 60, + sortable: false, + filter: false, + resizable: false, + suppressMenu: true, + pinned: false + }, + { + headerName: "Name", + field: "name", + cellRenderer: nameCellRenderer, + sort: "asc" + }, + { + headerName: "Assembly(s)", + field: "scriptWorkflow", + valueGetter: (params) => params.data?.scriptWorkflow || "Demonstration Component" + }, + { + headerName: "Target", + field: "target" + }, + { + headerName: "Recurrence", + field: "occurrence" + }, + { + headerName: "Last Run", + field: "lastRun" + }, + { + headerName: "Next Run", + field: "nextRun" + }, + { + headerName: "Results", + field: "resultsCounts", + minWidth: 280, + cellRenderer: resultsCellRenderer, + sortable: false, + filter: false + }, + { + headerName: "Enabled", + field: "enabled", + minWidth: 140, + maxWidth: 160, + cellRenderer: enabledCellRenderer, + sortable: false, + filter: false, + resizable: false, + suppressMenu: true + } + ], + [enabledCellRenderer, nameCellRenderer, resultsCellRenderer] + ); + + const defaultColDef = useMemo( + () => ({ + sortable: true, + filter: "agTextColumnFilter", + resizable: true, + flex: 1, + minWidth: 140, + cellStyle: { + display: "flex", + alignItems: "center", + color: "#f5f7fa", + fontFamily: gridFontFamily, + fontSize: "13px" + }, + headerClass: "scheduled-jobs-grid-header" + }), + [] + ); return ( - + - + Scheduled Jobs List of automation jobs with schedules, results, and actions. - + - - - - - toggleSelectAll(e.target.checked)} - /> - - {[ - ["name", "Name"], - ["scriptWorkflow", "Assembly(s)"], - ["target", "Target"], - ["occurrence", "Recurrence"], - ["lastRun", "Last Run"], - ["nextRun", "Next Run"], - ["results", "Results"], - ["enabled", "Enabled"] - ].map(([key, label]) => ( - - {key !== "results" ? ( - - handleSort(key)} - > - {label} - - {['name','occurrence','lastRun','nextRun'].includes(key) ? ( - - - - ) : null} - - ) : ( - label - )} - - ))} - - - - {sorted.map((r, i) => ( - - - toggleSelect(r.id, e.target.checked)} /> - - - - - {r.scriptWorkflow || "Demonstration Component"} - {r.target} - {r.occurrence} - {r.lastRun} - {r.nextRun} - - - - - { - try { - await fetch(`/api/scheduled_jobs/${r.id}/toggle`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ enabled: !r.enabled }) - }); - } catch {} - setRows((prev) => prev.map((job) => (job.id === r.id ? { ...job, enabled: !job.enabled } : job))); - }} - size="small" - /> - - - ))} - {sorted.length === 0 && ( - - - No scheduled jobs found. - - - )} - -
- setBulkDeleteOpen(false)} - PaperProps={{ sx: { bgcolor: '#121212', color: '#fff' } }} + + {loading && ( + + + Loading scheduled jobs… + + )} + + {error && ( + + {error} + + )} + + + + + + + + setBulkDeleteOpen(false)} + PaperProps={{ sx: { bgcolor: "#121212", color: "#fff" } }} > Are you sure you want to delete this job(s)? - - + + - - {/* Column filter popover */} - - {filterAnchor && ( - - { if (e.key === 'Escape') closeFilter(); }} - sx={{ - input: { color: '#fff' }, - minWidth: 220, - '& .MuiOutlinedInput-root': { '& fieldset': { borderColor: '#555' }, '&:hover fieldset': { borderColor: '#888' } } - }} - /> - - - )} -
); } From 16245335588a8a4a96a276cb3809a1953de54e4e Mon Sep 17 00:00:00 2001 From: Nicole Rappe Date: Thu, 16 Oct 2025 04:17:04 -0600 Subject: [PATCH 2/3] Tighten scheduled jobs results spacing --- .../src/Scheduling/Scheduled_Jobs_List.jsx | 25 +++++++++++++------ 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/Data/Server/WebUI/src/Scheduling/Scheduled_Jobs_List.jsx b/Data/Server/WebUI/src/Scheduling/Scheduled_Jobs_List.jsx index e7c18b6..5833ea1 100644 --- a/Data/Server/WebUI/src/Scheduling/Scheduled_Jobs_List.jsx +++ b/Data/Server/WebUI/src/Scheduling/Scheduled_Jobs_List.jsx @@ -65,14 +65,22 @@ function ResultsBar({ counts }) { .some((section) => Number(counts?.[section.key] || 0) > 0); return ( - + {sections.map((section) => { @@ -92,9 +100,10 @@ function ResultsBar({ counts }) { sx={{ display: "flex", flexWrap: "wrap", - gap: 1, + columnGap: 0.75, + rowGap: 0.25, color: "#aaa", - fontSize: 12, + fontSize: 11, fontFamily: gridFontFamily }} > @@ -108,13 +117,13 @@ function ResultsBar({ counts }) { Date: Thu, 16 Oct 2025 04:27:09 -0600 Subject: [PATCH 3/3] Updated Results Section to Fit Better into New Table --- Data/Server/WebUI/src/Scheduling/Scheduled_Jobs_List.jsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Data/Server/WebUI/src/Scheduling/Scheduled_Jobs_List.jsx b/Data/Server/WebUI/src/Scheduling/Scheduled_Jobs_List.jsx index 5833ea1..7e22a10 100644 --- a/Data/Server/WebUI/src/Scheduling/Scheduled_Jobs_List.jsx +++ b/Data/Server/WebUI/src/Scheduling/Scheduled_Jobs_List.jsx @@ -70,14 +70,14 @@ function ResultsBar({ counts }) { display: "flex", flexDirection: "column", gap: 0.25, - lineHeight: 1.1, + lineHeight: 1.7, fontFamily: gridFontFamily }} >