Merge pull request #100 from bunny-lab-io:codex/convert-material-ui-table-to-ag-grid

Refactor scheduled jobs list to Quartz-styled AG Grid
This commit is contained in:
2025-10-16 04:27:18 -06:00
committed by GitHub

View File

@@ -1,78 +1,214 @@
////////// PROJECT FILE SEPARATION LINE ////////// CODE AFTER THIS LINE ARE FROM: <ProjectRoot>/Data/WebUI/src/Scheduled_Jobs_List.jsx ////////// 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 { import {
Paper, Paper,
Box, Box,
Typography, Typography,
Button, Button,
Table,
TableBody,
TableCell,
TableHead,
TableRow,
TableSortLabel,
Switch, Switch,
Dialog, Dialog,
DialogTitle, DialogTitle,
DialogContent,
DialogActions, DialogActions,
Checkbox, CircularProgress
Popover,
TextField,
IconButton
} from "@mui/material"; } 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.25,
lineHeight: 1.7,
fontFamily: gridFontFamily
}}
>
<Box
sx={{
display: "flex",
borderRadius: 1,
overflow: "hidden",
width: 220,
height: 6
}}
>
{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",
columnGap: 0.75,
rowGap: 0.25,
color: "#aaa",
fontSize: 11,
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.5 }}
>
<Box
component="span"
sx={{
width: 6,
height: 6,
borderRadius: 1,
backgroundColor: section.color
}}
/>
{counts?.[section.key]} {labelFor(section.key)}
</Box>
));
})()}
</Box>
</Box>
);
}
export default function ScheduledJobsList({ onCreateJob, onEditJob, refreshToken }) { export default function ScheduledJobsList({ onCreateJob, onEditJob, refreshToken }) {
const [rows, setRows] = useState([]); const [rows, setRows] = useState([]);
const [orderBy, setOrderBy] = useState("name"); const [loading, setLoading] = useState(false);
const [order, setOrder] = useState("asc"); const [error, setError] = useState("");
const [selected, setSelected] = useState(new Set());
const [bulkDeleteOpen, setBulkDeleteOpen] = useState(false); const [bulkDeleteOpen, setBulkDeleteOpen] = useState(false);
const [filters, setFilters] = useState({}); // {name, occurrence, lastRun, nextRun} const [selectedIds, setSelectedIds] = useState(() => new Set());
const [filterAnchor, setFilterAnchor] = useState(null); // { id, anchorEl } const gridApiRef = useRef(null);
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 loadJobs = async () => { const loadJobs = useCallback(
async ({ showLoading = false } = {}) => {
if (showLoading) {
setLoading(true);
setError("");
}
try { try {
const resp = await fetch('/api/scheduled_jobs'); const resp = await fetch("/api/scheduled_jobs");
const data = await resp.json(); const data = await resp.json().catch(() => ({}));
if (!resp.ok) throw new Error(data.error || `HTTP ${resp.status}`); if (!resp.ok) {
throw new Error(data?.error || `HTTP ${resp.status}`);
}
const pretty = (st) => { const pretty = (st) => {
const s = String(st || '').toLowerCase(); const s = String(st || "").toLowerCase();
const map = { const map = {
'immediately': 'Immediately', immediately: "Immediately",
'once': 'Once', once: "Once",
'every_5_minutes': 'Every 5 Minutes', every_5_minutes: "Every 5 Minutes",
'every_10_minutes': 'Every 10 Minutes', every_10_minutes: "Every 10 Minutes",
'every_15_minutes': 'Every 15 Minutes', every_15_minutes: "Every 15 Minutes",
'every_30_minutes': 'Every 30 Minutes', every_30_minutes: "Every 30 Minutes",
'every_hour': 'Every Hour', every_hour: "Every Hour",
'daily': 'Daily', daily: "Daily",
'weekly': 'Weekly', weekly: "Weekly",
'monthly': 'Monthly', monthly: "Monthly",
'yearly': 'Yearly', yearly: "Yearly"
}; };
if (map[s]) return map[s]; if (map[s]) return map[s];
try { try {
return s.replace(/_/g, ' ').replace(/^./, c => c.toUpperCase()); return s.replace(/_/g, " ").replace(/^./, (c) => c.toUpperCase());
} catch { return String(st || ''); } } 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) => { const fmt = (ts) => {
if (!ts) return ''; if (!ts) return "";
try { try {
const d = new Date(Number(ts) * 1000); const d = new Date(Number(ts) * 1000);
return d.toLocaleString(undefined, { year:'numeric', month:'2-digit', day:'2-digit', hour:'numeric', minute:'2-digit' }); if (Number.isNaN(d?.getTime())) return "";
} catch { 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 { return {
id: j.id, id: j.id,
name: j.name, name: j.name,
@@ -81,317 +217,469 @@ export default function ScheduledJobsList({ onCreateJob, onEditJob, refreshToken
occurrence, occurrence,
lastRun: fmt(j.last_run_ts), lastRun: fmt(j.last_run_ts),
nextRun: fmt(j.next_run_ts || j.start_ts), nextRun: fmt(j.next_run_ts || j.start_ts),
result, result: j.last_status || (j.next_run_ts ? "Scheduled" : ""),
resultsCounts: j.result_counts || { pending: (Array.isArray(j.targets)?j.targets.length:0) }, resultsCounts,
enabled: !!j.enabled, enabled: Boolean(j.enabled),
raw: j raw: j
}; };
}); });
setRows(rows); setRows(mappedRows);
} catch (e) { setError("");
console.warn('Failed to load jobs', e); setSelectedIds((prev) => {
setRows([]); if (!prev.size) return prev;
} const valid = new Set(
}; mappedRows.map((row, index) => row.id ?? row.name ?? String(index))
);
// Initial load and polling each 5 seconds for live status updates let changed = false;
React.useEffect(() => { const next = new Set();
let timer; prev.forEach((value) => {
(async () => { try { await loadJobs(); } catch {} })(); if (valid.has(value)) {
timer = setInterval(loadJobs, 5000); next.add(value);
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 { } 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 ( return (
<div> <Button
<div style={{ display: 'flex', borderRadius: 2, overflow: 'hidden', width: 260 }}> onClick={handleClick}
{sections.map(({key,color}) => (s[key] ? <span key={key} style={styleSeg(color, seg(s[key]))} /> : null))} sx={{
</div> color: "#58a6ff",
<div style={{ color: '#aaa', fontSize: 12, marginTop: 4 }}> textTransform: "none",
{(() => { p: 0,
const nonPendingKeys = ['success','running','failed','timed_out','expired'].filter(k => s[k]); minWidth: 0,
if (nonPendingKeys.length === 0 && s['pending']) { fontFamily: gridFontFamily
// Pending-only: show simple "Scheduled" label under the bar }}
return <span>Scheduled</span>; >
{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 ( setRows((prev) =>
<> prev.map((job) => {
{['success','running','failed','timed_out','expired','pending'] if ((job.id ?? job.name) === (row.id ?? row.name)) {
.filter(k => s[k]) const updatedRaw = { ...(job.raw || {}), enabled: nextEnabled };
.map((k) => ( return { ...job, enabled: nextEnabled, raw: updatedRaw };
<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 }} /> return job;
{s[k]} {k === 'pending' ? 'Scheduled' : k.replace('_',' ').replace(/^./, c=>c.toUpperCase())} })
</span>
))}
</>
);
})()}
</div>
</div>
); );
}; };
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 ( 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 <Box
sx={{ sx={{
p: 2,
pb: 1,
display: "flex", display: "flex",
alignItems: "center",
justifyContent: "space-between", justifyContent: "space-between",
alignItems: "center" p: 2,
borderBottom: "1px solid #2a2a2a"
}} }}
> >
<Box> <Box>
<Typography variant="h6" sx={{ color: "#58a6ff", mb: 0 }}> <Typography variant="h6" sx={{ color: "#58a6ff", mb: 0.3 }}>
Scheduled Jobs Scheduled Jobs
</Typography> </Typography>
<Typography variant="body2" sx={{ color: "#aaa" }}> <Typography variant="body2" sx={{ color: "#aaa" }}>
List of automation jobs with schedules, results, and actions. List of automation jobs with schedules, results, and actions.
</Typography> </Typography>
</Box> </Box>
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}> <Box sx={{ display: "flex", gap: 1, alignItems: "center" }}>
<Button <Button
variant="outlined" variant="outlined"
size="small" size="small"
disabled={!anySelected} 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)} onClick={() => setBulkDeleteOpen(true)}
> >
Delete Job Delete Job
</Button> </Button>
<Button <Button
variant="outlined" variant="contained"
size="small" size="small"
sx={{ color: "#58a6ff", borderColor: "#58a6ff", textTransform: "none" }} sx={{
bgcolor: "#58a6ff",
color: "#0b0f19",
textTransform: "none",
fontFamily: gridFontFamily,
"&:hover": {
bgcolor: "#7db7ff"
}
}}
onClick={() => onCreateJob && onCreateJob()} onClick={() => onCreateJob && onCreateJob()}
> >
Create Job Create Job
</Button> </Button>
</Box> </Box>
</Box> </Box>
<Table size="small" sx={{ minWidth: 900 }}>
<TableHead> {loading && (
<TableRow> <Box
<TableCell width={40}> sx={{
<Checkbox display: "flex",
size="small" alignItems: "center",
checked={allSelected} gap: 1,
indeterminate={!allSelected && anySelected} color: "#7db7ff",
onChange={(e) => toggleSelectAll(e.target.checked)} px: 2,
/> py: 1.5,
</TableCell> borderBottom: "1px solid #2a2a2a"
{[
["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)));
}} }}
size="small" >
/> <CircularProgress size={18} sx={{ color: "#58a6ff" }} />
</TableCell> <Typography variant="body2">Loading scheduled jobs</Typography>
</TableRow> </Box>
))}
{sorted.length === 0 && (
<TableRow>
<TableCell colSpan={9} sx={{ color: "#888" }}>
No scheduled jobs found.
</TableCell>
</TableRow>
)} )}
</TableBody>
</Table> {error && (
<Dialog open={bulkDeleteOpen} onClose={() => setBulkDeleteOpen(false)} <Box sx={{ px: 2, py: 1.5, color: "#ff8080", borderBottom: "1px solid #2a2a2a" }}>
PaperProps={{ sx: { bgcolor: '#121212', color: '#fff' } }} <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> <DialogTitle>Are you sure you want to delete this job(s)?</DialogTitle>
<DialogActions> <DialogActions>
<Button onClick={() => setBulkDeleteOpen(false)} sx={{ color: '#58a6ff' }}>Cancel</Button> <Button onClick={() => setBulkDeleteOpen(false)} sx={{ color: "#58a6ff" }}>
<Button onClick={async () => { Cancel
</Button>
<Button
onClick={async () => {
try { try {
const ids = Array.from(selected); const ids = Array.from(selectedIds);
await Promise.allSettled(ids.map(id => fetch(`/api/scheduled_jobs/${id}`, { method: 'DELETE' }))); const idSet = new Set(ids);
setRows(prev => prev.filter(r => !selected.has(r.id))); await Promise.allSettled(
setSelected(new Set()); ids.map((id) => fetch(`/api/scheduled_jobs/${id}`, { method: "DELETE" }))
} catch {} );
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); setBulkDeleteOpen(false);
try { await loadJobs(); } catch {} await loadJobs({ showLoading: true });
}} variant="outlined" sx={{ color: '#58a6ff', borderColor: '#58a6ff' }}>Confirm</Button> }}
variant="outlined"
sx={{ color: "#58a6ff", borderColor: "#58a6ff" }}
>
Confirm
</Button>
</DialogActions> </DialogActions>
</Dialog> </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> </Paper>
); );
} }