Refactor scheduled jobs list to Quartz AG Grid

This commit is contained in:
2025-10-16 04:12:19 -06:00
parent 233f1ec036
commit 528400eaf3

View File

@@ -1,78 +1,205 @@
////////// PROJECT FILE SEPARATION LINE ////////// CODE AFTER THIS LINE ARE FROM: <ProjectRoot>/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 (
<Box sx={{ display: "flex", flexDirection: "column", gap: 0.5 }}>
<Box
sx={{
display: "flex",
borderRadius: 2,
overflow: "hidden",
width: 260,
height: 8
}}
>
{sections.map((section) => {
const value = Number(counts?.[section.key] || 0);
if (!value) return null;
const width = `${Math.round((value / total) * 100)}%`;
return (
<Box
key={section.key}
component="span"
sx={{ display: "block", height: "100%", width, backgroundColor: section.color }}
/>
);
})}
</Box>
<Box
sx={{
display: "flex",
flexWrap: "wrap",
gap: 1,
color: "#aaa",
fontSize: 12,
fontFamily: gridFontFamily
}}
>
{(() => {
if (!hasNonPending && Number(counts?.pending || 0) > 0) {
return <Box component="span">Scheduled</Box>;
}
return sections
.filter((section) => Number(counts?.[section.key] || 0) > 0)
.map((section) => (
<Box
key={section.key}
component="span"
sx={{ display: "inline-flex", alignItems: "center", gap: 0.75 }}
>
<Box
component="span"
sx={{
width: 8,
height: 8,
borderRadius: 1,
backgroundColor: section.color
}}
/>
{counts?.[section.key]} {labelFor(section.key)}
</Box>
));
})()}
</Box>
</Box>
);
}
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 () => {
const loadJobs = useCallback(
async ({ showLoading = false } = {}) => {
if (showLoading) {
setLoading(true);
setError("");
}
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 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 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',
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 || ''); }
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' : '');
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,
@@ -81,317 +208,469 @@ export default function ScheduledJobsList({ onCreateJob, onEditJob, refreshToken
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,
result: j.last_status || (j.next_run_ts ? "Scheduled" : ""),
resultsCounts,
enabled: Boolean(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)));
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 {
setSelected(new Set());
changed = true;
}
});
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);
}
};
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 (
<div>
<div style={{ display: 'flex', borderRadius: 2, overflow: 'hidden', width: 260 }}>
{sections.map(({key,color}) => (s[key] ? <span key={key} style={styleSeg(color, seg(s[key]))} /> : null))}
</div>
<div style={{ color: '#aaa', fontSize: 12, marginTop: 4 }}>
{(() => {
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 <span>Scheduled</span>;
<Button
onClick={handleClick}
sx={{
color: "#58a6ff",
textTransform: "none",
p: 0,
minWidth: 0,
fontFamily: gridFontFamily
}}
>
{row.name || "-"}
</Button>
);
},
[onEditJob]
);
const resultsCellRenderer = useCallback((params) => {
return <ResultsBar counts={params?.data?.resultsCounts} />;
}, []);
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
}
return (
<>
{['success','running','failed','timed_out','expired','pending']
.filter(k => s[k])
.map((k) => (
<span key={k} style={{ marginRight: 10 }}>
<span style={{ display: 'inline-block', width: 8, height: 8, background: sections.find(x=>x.key===k).color, marginRight: 6 }} />
{s[k]} {k === 'pending' ? 'Scheduled' : k.replace('_',' ').replace(/^./, c=>c.toUpperCase())}
</span>
))}
</>
);
})()}
</div>
</div>
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 (
<Switch
size="small"
checked={Boolean(row.enabled)}
onChange={handleToggle}
onClick={(event) => 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 (
<Paper sx={{ m: 2, p: 0, bgcolor: "#1e1e1e" }} elevation={2}>
<Paper
sx={{
m: 2,
p: 0,
bgcolor: "#1e1e1e",
color: "#f5f7fa",
fontFamily: gridFontFamily,
display: "flex",
flexDirection: "column",
flexGrow: 1,
minWidth: 0,
minHeight: 420
}}
elevation={2}
>
<Box
sx={{
p: 2,
pb: 1,
display: "flex",
alignItems: "center",
justifyContent: "space-between",
alignItems: "center"
p: 2,
borderBottom: "1px solid #2a2a2a"
}}
>
<Box>
<Typography variant="h6" sx={{ color: "#58a6ff", mb: 0 }}>
<Typography variant="h6" sx={{ color: "#58a6ff", mb: 0.3 }}>
Scheduled Jobs
</Typography>
<Typography variant="body2" sx={{ color: "#aaa" }}>
List of automation jobs with schedules, results, and actions.
</Typography>
</Box>
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
<Box sx={{ display: "flex", gap: 1, alignItems: "center" }}>
<Button
variant="outlined"
size="small"
disabled={!anySelected}
sx={{ color: anySelected ? "#ff6666" : "#666", borderColor: anySelected ? "#ff6666" : "#444", textTransform: "none" }}
sx={{
color: anySelected ? "#ff8080" : "#666",
borderColor: anySelected ? "#ff8080" : "#333",
textTransform: "none",
fontFamily: gridFontFamily,
"&:hover": {
borderColor: anySelected ? "#ff8080" : "#333"
}
}}
onClick={() => setBulkDeleteOpen(true)}
>
Delete Job
</Button>
<Button
variant="outlined"
variant="contained"
size="small"
sx={{ color: "#58a6ff", borderColor: "#58a6ff", textTransform: "none" }}
sx={{
bgcolor: "#58a6ff",
color: "#0b0f19",
textTransform: "none",
fontFamily: gridFontFamily,
"&:hover": {
bgcolor: "#7db7ff"
}
}}
onClick={() => onCreateJob && onCreateJob()}
>
Create Job
</Button>
</Box>
</Box>
<Table size="small" sx={{ minWidth: 900 }}>
<TableHead>
<TableRow>
<TableCell width={40}>
<Checkbox
size="small"
checked={allSelected}
indeterminate={!allSelected && anySelected}
onChange={(e) => toggleSelectAll(e.target.checked)}
/>
</TableCell>
{[
["name", "Name"],
["scriptWorkflow", "Assembly(s)"],
["target", "Target"],
["occurrence", "Recurrence"],
["lastRun", "Last Run"],
["nextRun", "Next Run"],
["results", "Results"],
["enabled", "Enabled"]
].map(([key, label]) => (
<TableCell key={key} sortDirection={orderBy === key ? order : false}>
{key !== "results" ? (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<TableSortLabel
active={orderBy === key}
direction={orderBy === key ? order : "asc"}
onClick={() => handleSort(key)}
>
{label}
</TableSortLabel>
{['name','occurrence','lastRun','nextRun'].includes(key) ? (
<IconButton size="small" onClick={openFilter(key)} sx={{ color: filters[key] ? '#58a6ff' : '#888' }}>
<FilterListIcon fontSize="inherit" />
</IconButton>
) : null}
</Box>
) : (
label
)}
</TableCell>
))}
</TableRow>
</TableHead>
<TableBody>
{sorted.map((r, i) => (
<TableRow key={i} hover>
<TableCell width={40}>
<Checkbox size="small" checked={selected.has(r.id)} onChange={(e) => toggleSelect(r.id, e.target.checked)} />
</TableCell>
<TableCell>
<Button onClick={() => { const job = r.raw; if (job && typeof onEditJob === 'function') onEditJob(job); }}
sx={{ color: '#58a6ff', textTransform: 'none', p: 0, minWidth: 0 }}
>
{r.name}
</Button>
</TableCell>
<TableCell>{r.scriptWorkflow || "Demonstration Component"}</TableCell>
<TableCell>{r.target}</TableCell>
<TableCell>{r.occurrence}</TableCell>
<TableCell>{r.lastRun}</TableCell>
<TableCell>{r.nextRun}</TableCell>
<TableCell>
<ResultsBar counts={r.resultsCounts} />
</TableCell>
<TableCell>
<Switch
checked={r.enabled}
onChange={async () => {
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)));
{loading && (
<Box
sx={{
display: "flex",
alignItems: "center",
gap: 1,
color: "#7db7ff",
px: 2,
py: 1.5,
borderBottom: "1px solid #2a2a2a"
}}
size="small"
/>
</TableCell>
</TableRow>
))}
{sorted.length === 0 && (
<TableRow>
<TableCell colSpan={9} sx={{ color: "#888" }}>
No scheduled jobs found.
</TableCell>
</TableRow>
>
<CircularProgress size={18} sx={{ color: "#58a6ff" }} />
<Typography variant="body2">Loading scheduled jobs</Typography>
</Box>
)}
</TableBody>
</Table>
<Dialog open={bulkDeleteOpen} onClose={() => setBulkDeleteOpen(false)}
PaperProps={{ sx: { bgcolor: '#121212', color: '#fff' } }}
{error && (
<Box sx={{ px: 2, py: 1.5, color: "#ff8080", borderBottom: "1px solid #2a2a2a" }}>
<Typography variant="body2">{error}</Typography>
</Box>
)}
<Box
sx={{
flexGrow: 1,
minHeight: 0,
display: "flex",
flexDirection: "column",
mt: "10px",
px: 2,
pb: 2
}}
>
<Box
className={themeClassName}
sx={{
width: "100%",
height: "100%",
flexGrow: 1,
fontFamily: gridFontFamily,
"--ag-font-family": gridFontFamily,
"--ag-icon-font-family": iconFontFamily,
"--ag-row-border-style": "solid",
"--ag-row-border-color": "#2a2a2a",
"--ag-row-border-width": "1px",
"& .ag-root-wrapper": {
borderRadius: 1,
minHeight: 320
},
"& .ag-root, & .ag-header, & .ag-center-cols-container, & .ag-paging-panel": {
fontFamily: gridFontFamily
},
"& .ag-icon": {
fontFamily: iconFontFamily
},
"& .scheduled-jobs-grid-header": {
fontFamily: gridFontFamily,
fontWeight: 600,
color: "#f5f7fa"
}
}}
>
<AgGridReact
rowData={rows}
columnDefs={columnDefs}
defaultColDef={defaultColDef}
animateRows
rowHeight={46}
headerHeight={44}
suppressCellFocus
rowSelection="multiple"
rowMultiSelectWithClick
suppressRowClickSelection
getRowId={getRowId}
overlayNoRowsTemplate="<span class='ag-overlay-no-rows-center'>No scheduled jobs found.</span>"
onGridReady={handleGridReady}
onSelectionChanged={handleSelectionChanged}
theme={myTheme}
style={{
width: "100%",
height: "100%",
fontFamily: gridFontFamily,
"--ag-icon-font-family": iconFontFamily
}}
/>
</Box>
</Box>
<Dialog
open={bulkDeleteOpen}
onClose={() => setBulkDeleteOpen(false)}
PaperProps={{ sx: { bgcolor: "#121212", color: "#fff" } }}
>
<DialogTitle>Are you sure you want to delete this job(s)?</DialogTitle>
<DialogActions>
<Button onClick={() => setBulkDeleteOpen(false)} sx={{ color: '#58a6ff' }}>Cancel</Button>
<Button onClick={async () => {
<Button onClick={() => setBulkDeleteOpen(false)} sx={{ color: "#58a6ff" }}>
Cancel
</Button>
<Button
onClick={async () => {
try {
const ids = Array.from(selected);
await Promise.allSettled(ids.map(id => fetch(`/api/scheduled_jobs/${id}`, { method: 'DELETE' })));
setRows(prev => prev.filter(r => !selected.has(r.id)));
setSelected(new Set());
} catch {}
const ids = Array.from(selectedIds);
const idSet = new Set(ids);
await Promise.allSettled(
ids.map((id) => fetch(`/api/scheduled_jobs/${id}`, { method: "DELETE" }))
);
setRows((prev) =>
prev.filter((job, index) => {
const key = getRowId({ data: job, rowIndex: index });
return !idSet.has(key);
})
);
setSelectedIds(() => new Set());
} catch {
// ignore delete errors here; a fresh load will surface them
}
setBulkDeleteOpen(false);
try { await loadJobs(); } catch {}
}} variant="outlined" sx={{ color: '#58a6ff', borderColor: '#58a6ff' }}>Confirm</Button>
await loadJobs({ showLoading: true });
}}
variant="outlined"
sx={{ color: "#58a6ff", borderColor: "#58a6ff" }}
>
Confirm
</Button>
</DialogActions>
</Dialog>
{/* Column filter popover */}
<Popover
open={Boolean(filterAnchor)}
anchorEl={filterAnchor?.anchorEl || null}
onClose={closeFilter}
anchorOrigin={{ vertical: 'bottom', horizontal: 'left' }}
PaperProps={{ sx: { bgcolor: '#1e1e1e', p: 1 } }}
>
{filterAnchor && (
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
<TextField
autoFocus
size="small"
placeholder={`Filter ${(
{
name: 'Name',
occurrence: 'Recurrence',
lastRun: 'Last Run',
nextRun: 'Next Run'
})[filterAnchor.id] || ''}`}
value={filters[filterAnchor.id] || ''}
onChange={onFilterChange(filterAnchor.id)}
onKeyDown={(e) => { if (e.key === 'Escape') closeFilter(); }}
sx={{
input: { color: '#fff' },
minWidth: 220,
'& .MuiOutlinedInput-root': { '& fieldset': { borderColor: '#555' }, '&:hover fieldset': { borderColor: '#888' } }
}}
/>
<Button
variant="outlined"
size="small"
onClick={() => { setFilters((prev) => ({ ...prev, [filterAnchor.id]: '' })); closeFilter(); }}
sx={{ textTransform: 'none', borderColor: '#555', color: '#bbb' }}
>
Clear
</Button>
</Box>
)}
</Popover>
</Paper>
);
}