mirror of
https://github.com/bunny-lab-io/Borealis.git
synced 2025-10-26 17:41:58 -06:00
Added Functional Job Scheduling for Scripts
This commit is contained in:
@@ -46,6 +46,15 @@ function SectionHeader({ title, action }) {
|
||||
);
|
||||
}
|
||||
|
||||
// Recursive renderer for both Scripts and Workflows trees
|
||||
function renderTreeNodes(nodes = [], map = {}) {
|
||||
return nodes.map((n) => (
|
||||
<TreeItem key={n.id} itemId={n.id} label={n.label}>
|
||||
{n.children && n.children.length ? renderTreeNodes(n.children, map) : null}
|
||||
</TreeItem>
|
||||
));
|
||||
}
|
||||
|
||||
// --- Scripts tree helpers (reuse approach from Quick_Job) ---
|
||||
function buildScriptTree(scripts, folders) {
|
||||
const map = {};
|
||||
@@ -112,7 +121,7 @@ function buildWorkflowTree(workflows, folders) {
|
||||
|
||||
function ComponentCard({ comp, onRemove }) {
|
||||
return (
|
||||
<Paper sx={{ bgcolor: "#232323", border: "1px solid #333", p: 1, mb: 1 }}>
|
||||
<Paper sx={{ bgcolor: "#2a2a2a", border: "1px solid #3a3a3a", p: 1.2, mb: 1.2, borderRadius: 1 }}>
|
||||
<Box sx={{ display: "flex", gap: 2 }}>
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Typography variant="subtitle2" sx={{ color: "#e6edf3" }}>
|
||||
@@ -154,10 +163,8 @@ function ComponentCard({ comp, onRemove }) {
|
||||
export default function CreateJob({ onCancel, onCreated, initialJob = null }) {
|
||||
const [tab, setTab] = useState(0);
|
||||
const [jobName, setJobName] = useState("");
|
||||
// Pre-seed with a placeholder component to keep flows working during UI build-out
|
||||
const [components, setComponents] = useState([
|
||||
{ type: "script", path: "demo/component", name: "Demonstration Component", description: "placeholder" }
|
||||
]); // {type:'script'|'workflow', path, name, description}
|
||||
// Components the job will run: {type:'script'|'workflow', path, name, description}
|
||||
const [components, setComponents] = useState([]);
|
||||
const [targets, setTargets] = useState([]); // array of hostnames
|
||||
const [scheduleType, setScheduleType] = useState("immediately");
|
||||
const [startDateTime, setStartDateTime] = useState(() => dayjs().add(5, "minute").second(0));
|
||||
@@ -345,7 +352,7 @@ export default function CreateJob({ onCancel, onCreated, initialJob = null }) {
|
||||
if (initialJob && initialJob.id) {
|
||||
setJobName(initialJob.name || "");
|
||||
const comps = Array.isArray(initialJob.components) ? initialJob.components : [];
|
||||
setComponents(comps.length ? comps : [{ type: "script", path: "demo/component", name: "Demonstration Component", description: "placeholder" }]);
|
||||
setComponents(comps.length ? comps : []);
|
||||
setTargets(Array.isArray(initialJob.targets) ? initialJob.targets : []);
|
||||
setScheduleType(initialJob.schedule_type || initialJob.schedule?.type || "immediately");
|
||||
setStartDateTime(initialJob.start_ts ? dayjs(Number(initialJob.start_ts) * 1000).second(0) : (initialJob.schedule?.start ? dayjs(initialJob.schedule.start).second(0) : dayjs().add(5, "minute").second(0)));
|
||||
@@ -380,19 +387,20 @@ export default function CreateJob({ onCancel, onCreated, initialJob = null }) {
|
||||
const addSelectedComponent = () => {
|
||||
const map = compTab === "scripts" ? scriptMap : workflowMap;
|
||||
const node = map[selectedNodeId];
|
||||
if (!node || node.isFolder) return;
|
||||
if (!node || node.isFolder) return false;
|
||||
if (compTab === "scripts" && node.script) {
|
||||
setComponents((prev) => [
|
||||
...prev,
|
||||
{ type: "script", path: node.path, name: node.fileName || node.label, description: node.path }
|
||||
]);
|
||||
setSelectedNodeId("");
|
||||
return true;
|
||||
} else if (compTab === "workflows" && node.workflow) {
|
||||
setComponents((prev) => [
|
||||
...prev,
|
||||
{ type: "workflow", path: node.path, name: node.label, description: node.path }
|
||||
]);
|
||||
alert("Workflows within Scheduled Jobs are not supported yet");
|
||||
return false;
|
||||
}
|
||||
setSelectedNodeId("");
|
||||
return false;
|
||||
};
|
||||
|
||||
const openAddTargets = async () => {
|
||||
@@ -659,7 +667,8 @@ export default function CreateJob({ onCancel, onCreated, initialJob = null }) {
|
||||
</Box>
|
||||
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<Typography variant="subtitle1" sx={{ color: '#7db7ff', mb: 1 }}>Devices</Typography>
|
||||
<Typography variant="subtitle1" sx={{ color: '#7db7ff', mb: 0.5 }}>Devices</Typography>
|
||||
<Typography variant="caption" sx={{ color: '#aaa' }}>Devices targeted by this scheduled job. Individual job history is listed here.</Typography>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
@@ -700,7 +709,11 @@ export default function CreateJob({ onCancel, onCreated, initialJob = null }) {
|
||||
</Box>
|
||||
|
||||
<Box sx={{ mt: 2 }}>
|
||||
{renderHistory()}
|
||||
<Typography variant="subtitle1" sx={{ color: '#7db7ff', mb: 0.5 }}>Past Job History</Typography>
|
||||
<Typography variant="caption" sx={{ color: '#aaa' }}>Historical job history summaries. Detailed job history is not recorded.</Typography>
|
||||
<Box sx={{ mt: 1 }}>
|
||||
{renderHistory()}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
@@ -726,14 +739,15 @@ export default function CreateJob({ onCancel, onCreated, initialJob = null }) {
|
||||
</Box>
|
||||
{compTab === "scripts" && (
|
||||
<Paper sx={{ p: 1, bgcolor: "#1e1e1e", maxHeight: 400, overflow: "auto" }}>
|
||||
<SimpleTreeView onItemSelectionToggle={(_, id) => setSelectedNodeId(id)}>
|
||||
{scriptTree.length ? scriptTree.map((n) => (
|
||||
<SimpleTreeView onItemSelectionToggle={(_, id) => {
|
||||
const n = scriptMap[id];
|
||||
if (n && !n.isFolder) setSelectedNodeId(id);
|
||||
}}>
|
||||
{scriptTree.length ? (scriptTree.map((n) => (
|
||||
<TreeItem key={n.id} itemId={n.id} label={n.label}>
|
||||
{n.children?.map((c) => (
|
||||
<TreeItem key={c.id} itemId={c.id} label={c.label} />
|
||||
))}
|
||||
{n.children && n.children.length ? renderTreeNodes(n.children, scriptMap) : null}
|
||||
</TreeItem>
|
||||
)) : (
|
||||
))) : (
|
||||
<Typography variant="body2" sx={{ color: "#888", p: 1 }}>No scripts found.</Typography>
|
||||
)}
|
||||
</SimpleTreeView>
|
||||
@@ -741,14 +755,15 @@ export default function CreateJob({ onCancel, onCreated, initialJob = null }) {
|
||||
)}
|
||||
{compTab === "workflows" && (
|
||||
<Paper sx={{ p: 1, bgcolor: "#1e1e1e", maxHeight: 400, overflow: "auto" }}>
|
||||
<SimpleTreeView onItemSelectionToggle={(_, id) => setSelectedNodeId(id)}>
|
||||
{workflowTree.length ? workflowTree.map((n) => (
|
||||
<SimpleTreeView onItemSelectionToggle={(_, id) => {
|
||||
const n = workflowMap[id];
|
||||
if (n && !n.isFolder) setSelectedNodeId(id);
|
||||
}}>
|
||||
{workflowTree.length ? (workflowTree.map((n) => (
|
||||
<TreeItem key={n.id} itemId={n.id} label={n.label}>
|
||||
{n.children?.map((c) => (
|
||||
<TreeItem key={c.id} itemId={c.id} label={c.label} />
|
||||
))}
|
||||
{n.children && n.children.length ? renderTreeNodes(n.children, workflowMap) : null}
|
||||
</TreeItem>
|
||||
)) : (
|
||||
))) : (
|
||||
<Typography variant="body2" sx={{ color: "#888", p: 1 }}>No workflows found.</Typography>
|
||||
)}
|
||||
</SimpleTreeView>
|
||||
@@ -757,7 +772,7 @@ export default function CreateJob({ onCancel, onCreated, initialJob = null }) {
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setAddCompOpen(false)} sx={{ color: "#58a6ff" }}>Close</Button>
|
||||
<Button onClick={() => { addSelectedComponent(); setAddCompOpen(false); }}
|
||||
<Button onClick={() => { const ok = addSelectedComponent(); if (ok) setAddCompOpen(false); }}
|
||||
sx={{ color: "#58a6ff" }} disabled={!selectedNodeId}
|
||||
>Add</Button>
|
||||
</DialogActions>
|
||||
|
||||
@@ -13,23 +13,28 @@ import {
|
||||
TableRow,
|
||||
TableSortLabel,
|
||||
Switch,
|
||||
IconButton,
|
||||
Menu,
|
||||
MenuItem,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions
|
||||
DialogActions,
|
||||
Checkbox,
|
||||
Popover,
|
||||
TextField,
|
||||
IconButton
|
||||
} from "@mui/material";
|
||||
import { MoreHoriz as MoreHorizIcon } from "@mui/icons-material";
|
||||
import FilterListIcon from "@mui/icons-material/FilterList";
|
||||
|
||||
export default function ScheduledJobsList({ onCreateJob, onEditJob, refreshToken }) {
|
||||
const [rows, setRows] = useState([]);
|
||||
const [orderBy, setOrderBy] = useState("name");
|
||||
const [order, setOrder] = useState("asc");
|
||||
const [menuAnchor, setMenuAnchor] = useState(null);
|
||||
const [menuRow, setMenuRow] = useState(null);
|
||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||
const [selected, setSelected] = useState(new Set());
|
||||
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 loadJobs = async () => {
|
||||
try {
|
||||
@@ -85,14 +90,43 @@ export default function ScheduledJobsList({ onCreateJob, onEditJob, refreshToken
|
||||
}
|
||||
};
|
||||
|
||||
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 [...rows].sort((a, b) => {
|
||||
return [...filtered].sort((a, b) => {
|
||||
const A = a[orderBy] || "";
|
||||
const B = b[orderBy] || "";
|
||||
return String(A).localeCompare(String(B)) * dir;
|
||||
});
|
||||
}, [rows, orderBy, order]);
|
||||
}, [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';
|
||||
@@ -124,14 +158,25 @@ export default function ScheduledJobsList({ onCreateJob, onEditJob, refreshToken
|
||||
{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 }}>
|
||||
{['success','running','failed','timed_out','expired','pending']
|
||||
.filter(k => s[k])
|
||||
.map((k,i) => (
|
||||
<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.replace('_',' ').replace(/^./, c=>c.toUpperCase())}
|
||||
</span>
|
||||
))}
|
||||
{(() => {
|
||||
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>;
|
||||
}
|
||||
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>
|
||||
);
|
||||
@@ -156,39 +201,63 @@ export default function ScheduledJobsList({ onCreateJob, onEditJob, refreshToken
|
||||
List of automation jobs with schedules, results, and actions.
|
||||
</Typography>
|
||||
</Box>
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
sx={{ color: "#58a6ff", borderColor: "#58a6ff", textTransform: "none" }}
|
||||
onClick={() => onCreateJob && onCreateJob()}
|
||||
>
|
||||
Create Job
|
||||
</Button>
|
||||
<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" }}
|
||||
onClick={() => setBulkDeleteOpen(true)}
|
||||
>
|
||||
Delete Job
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
sx={{ color: "#58a6ff", borderColor: "#58a6ff", textTransform: "none" }}
|
||||
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", "Script / Workflow"],
|
||||
["target", "Target"],
|
||||
["occurrence", "Schedule Occurrence"],
|
||||
["occurrence", "Recurrence"],
|
||||
["lastRun", "Last Run"],
|
||||
["nextRun", "Next Run"],
|
||||
["result", "Result"],
|
||||
["results", "Results"],
|
||||
["enabled", "Enabled"],
|
||||
["edit", "Edit Job"]
|
||||
["results", "Results"],
|
||||
["enabled", "Enabled"]
|
||||
].map(([key, label]) => (
|
||||
<TableCell key={key} sortDirection={orderBy === key ? order : false}>
|
||||
{key !== "edit" && key !== "results" ? (
|
||||
<TableSortLabel
|
||||
active={orderBy === key}
|
||||
direction={orderBy === key ? order : "asc"}
|
||||
onClick={() => handleSort(key)}
|
||||
>
|
||||
{label}
|
||||
</TableSortLabel>
|
||||
{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
|
||||
)}
|
||||
@@ -199,26 +268,21 @@ export default function ScheduledJobsList({ onCreateJob, onEditJob, refreshToken
|
||||
<TableBody>
|
||||
{sorted.map((r, i) => (
|
||||
<TableRow key={i} hover>
|
||||
<TableCell>{r.name}</TableCell>
|
||||
<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>
|
||||
<span
|
||||
style={{
|
||||
display: "inline-block",
|
||||
width: 10,
|
||||
height: 10,
|
||||
borderRadius: 10,
|
||||
background: resultColor(r.result),
|
||||
marginRight: 8,
|
||||
verticalAlign: "middle"
|
||||
}}
|
||||
/>
|
||||
{r.result}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<ResultsBar counts={r.resultsCounts} />
|
||||
</TableCell>
|
||||
@@ -238,11 +302,6 @@ export default function ScheduledJobsList({ onCreateJob, onEditJob, refreshToken
|
||||
size="small"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<IconButton size="small" onClick={(e) => { setMenuAnchor(e.currentTarget); setMenuRow(r); }} sx={{ color: "#58a6ff" }}>
|
||||
<MoreHorizIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{sorted.length === 0 && (
|
||||
@@ -254,45 +313,65 @@ export default function ScheduledJobsList({ onCreateJob, onEditJob, refreshToken
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<Menu
|
||||
open={Boolean(menuAnchor)}
|
||||
anchorEl={menuAnchor}
|
||||
onClose={() => { setMenuAnchor(null); setMenuRow(null); }}
|
||||
PaperProps={{ sx: { bgcolor: "#1e1e1e", color: "#fff", fontSize: "13px" } }}
|
||||
>
|
||||
<MenuItem onClick={() => {
|
||||
const job = menuRow?.raw;
|
||||
setMenuAnchor(null); setMenuRow(null);
|
||||
if (job && onEditJob) onEditJob(job);
|
||||
}}>Edit</MenuItem>
|
||||
<MenuItem onClick={() => { setMenuAnchor(null); setDeleteOpen(true); }}>Delete</MenuItem>
|
||||
</Menu>
|
||||
|
||||
<Dialog open={deleteOpen} onClose={() => setDeleteOpen(false)}
|
||||
<Dialog open={bulkDeleteOpen} onClose={() => setBulkDeleteOpen(false)}
|
||||
PaperProps={{ sx: { bgcolor: '#121212', color: '#fff' } }}
|
||||
>
|
||||
<DialogTitle>Delete this Job?</DialogTitle>
|
||||
<DialogContent>
|
||||
<Typography variant="body2" sx={{ color: '#aaa' }}>
|
||||
This will permanently remove the scheduled job from the list.
|
||||
</Typography>
|
||||
</DialogContent>
|
||||
<DialogTitle>Are you sure you want to delete this job(s)?</DialogTitle>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setDeleteOpen(false)} sx={{ color: '#58a6ff' }}>Cancel</Button>
|
||||
<Button onClick={() => setBulkDeleteOpen(false)} sx={{ color: '#58a6ff' }}>Cancel</Button>
|
||||
<Button onClick={async () => {
|
||||
try {
|
||||
if (menuRow?.id) {
|
||||
await fetch(`/api/scheduled_jobs/${menuRow.id}`, { method: 'DELETE' });
|
||||
setRows((prev) => prev.filter((r) => r.id !== menuRow.id));
|
||||
}
|
||||
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 {}
|
||||
setDeleteOpen(false);
|
||||
setMenuRow(null);
|
||||
// Optionally reload to be safe
|
||||
setBulkDeleteOpen(false);
|
||||
try { await loadJobs(); } catch {}
|
||||
}} variant="outlined" sx={{ color: '#58a6ff', borderColor: '#58a6ff' }}>Delete</Button>
|
||||
}} 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -130,6 +130,93 @@ class JobScheduler:
|
||||
# Bind routes
|
||||
self._register_routes()
|
||||
|
||||
# ---------- Helpers for dispatching scripts ----------
|
||||
def _scripts_root(self) -> str:
|
||||
import os
|
||||
return os.path.abspath(
|
||||
os.path.join(os.path.dirname(__file__), "..", "..", "Scripts")
|
||||
)
|
||||
|
||||
def _detect_script_type(self, filename: str) -> str:
|
||||
fn = (filename or "").lower()
|
||||
if fn.endswith(".yml"):
|
||||
return "ansible"
|
||||
if fn.endswith(".ps1"):
|
||||
return "powershell"
|
||||
if fn.endswith(".bat"):
|
||||
return "batch"
|
||||
if fn.endswith(".sh"):
|
||||
return "bash"
|
||||
return "unknown"
|
||||
|
||||
def _dispatch_script(self, hostname: str, rel_path: str, run_mode: str) -> None:
|
||||
"""Emit a quick_job_run event to agents for the given script/host.
|
||||
Mirrors /api/scripts/quick_run behavior for scheduled jobs.
|
||||
"""
|
||||
try:
|
||||
scripts_root = self._scripts_root()
|
||||
import os
|
||||
path_norm = (rel_path or "").replace("\\", "/")
|
||||
abs_path = os.path.abspath(os.path.join(scripts_root, path_norm))
|
||||
if not abs_path.startswith(scripts_root) or not os.path.isfile(abs_path):
|
||||
return
|
||||
stype = self._detect_script_type(abs_path)
|
||||
# For now, only PowerShell is supported by agents for scheduled jobs
|
||||
if stype != "powershell":
|
||||
return
|
||||
try:
|
||||
with open(abs_path, "r", encoding="utf-8", errors="replace") as fh:
|
||||
content = fh.read()
|
||||
except Exception:
|
||||
return
|
||||
|
||||
# Insert into activity_history for device for parity with Quick Job
|
||||
import sqlite3
|
||||
now = _now_ts()
|
||||
act_id = None
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
cur = conn.cursor()
|
||||
try:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO activity_history(hostname, script_path, script_name, script_type, ran_at, status, stdout, stderr)
|
||||
VALUES(?,?,?,?,?,?,?,?)
|
||||
""",
|
||||
(
|
||||
str(hostname),
|
||||
path_norm,
|
||||
os.path.basename(abs_path),
|
||||
stype,
|
||||
now,
|
||||
"Running",
|
||||
"",
|
||||
"",
|
||||
),
|
||||
)
|
||||
act_id = cur.lastrowid
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
payload = {
|
||||
"job_id": act_id,
|
||||
"target_hostname": str(hostname),
|
||||
"script_type": stype,
|
||||
"script_name": os.path.basename(abs_path),
|
||||
"script_path": path_norm,
|
||||
"script_content": content,
|
||||
"run_mode": (run_mode or "system").strip().lower(),
|
||||
"admin_user": "",
|
||||
"admin_pass": "",
|
||||
}
|
||||
try:
|
||||
self.socketio.emit("quick_job_run", payload)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
# Keep scheduler resilient
|
||||
pass
|
||||
|
||||
# ---------- DB helpers ----------
|
||||
def _conn(self):
|
||||
return sqlite3.connect(self.db_path)
|
||||
@@ -310,7 +397,7 @@ class JobScheduler:
|
||||
pass
|
||||
try:
|
||||
cur.execute(
|
||||
"SELECT id, schedule_type, start_ts, enabled, expiration, targets_json, created_at FROM scheduled_jobs WHERE enabled=1 ORDER BY id ASC"
|
||||
"SELECT id, components_json, targets_json, schedule_type, start_ts, expiration, execution_context, created_at FROM scheduled_jobs WHERE enabled=1 ORDER BY id ASC"
|
||||
)
|
||||
jobs = cur.fetchall()
|
||||
except Exception:
|
||||
@@ -328,7 +415,7 @@ class JobScheduler:
|
||||
five_min = 300
|
||||
now_min = _now_minute()
|
||||
|
||||
for (job_id, schedule_type, start_ts, enabled, expiration, targets_json, created_at) in jobs:
|
||||
for (job_id, components_json, targets_json, schedule_type, start_ts, expiration, execution_context, created_at) in jobs:
|
||||
try:
|
||||
# Targets list for this job
|
||||
try:
|
||||
@@ -338,6 +425,22 @@ class JobScheduler:
|
||||
targets = [str(t) for t in targets if isinstance(t, (str, int))]
|
||||
total_targets = len(targets)
|
||||
|
||||
# Determine scripts to run for this job (first-pass: all 'script' components)
|
||||
try:
|
||||
comps = json.loads(components_json or "[]")
|
||||
except Exception:
|
||||
comps = []
|
||||
script_paths = []
|
||||
for c in comps:
|
||||
try:
|
||||
if (c or {}).get("type") == "script":
|
||||
p = (c.get("path") or c.get("script_path") or "").strip()
|
||||
if p:
|
||||
script_paths.append(p)
|
||||
except Exception:
|
||||
continue
|
||||
run_mode = (execution_context or "system").strip().lower()
|
||||
|
||||
exp_seconds = _parse_expiration(expiration)
|
||||
|
||||
# Determine current occurrence to work on
|
||||
@@ -408,7 +511,7 @@ class JobScheduler:
|
||||
except Exception:
|
||||
row = None
|
||||
if row:
|
||||
# Existing record — if Running, timeout handled earlier; skip
|
||||
# Existing record - if Running, timeout handled earlier; skip
|
||||
conn2.close()
|
||||
continue
|
||||
|
||||
@@ -421,6 +524,12 @@ class JobScheduler:
|
||||
(job_id, host, occ, ts_now, "Running", ts_now, ts_now),
|
||||
)
|
||||
conn2.commit()
|
||||
# Dispatch all script components for this job to the target host
|
||||
for sp in script_paths:
|
||||
try:
|
||||
self._dispatch_script(host, sp, run_mode)
|
||||
except Exception:
|
||||
continue
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
@@ -877,13 +986,36 @@ class JobScheduler:
|
||||
|
||||
@app.route("/api/scheduled_jobs/<int:job_id>/runs", methods=["DELETE"])
|
||||
def api_scheduled_job_runs_clear(job_id: int):
|
||||
"""Clear all historical runs for a job except the most recent occurrence.
|
||||
|
||||
We keep all rows that belong to the latest occurrence (by scheduled_ts)
|
||||
and delete everything older. If there is no occurrence, no-op.
|
||||
"""
|
||||
try:
|
||||
conn = self._conn()
|
||||
cur = conn.cursor()
|
||||
cur.execute("DELETE FROM scheduled_job_runs WHERE job_id=?", (job_id,))
|
||||
# Determine latest occurrence for this job
|
||||
cur.execute(
|
||||
"SELECT MAX(scheduled_ts) FROM scheduled_job_runs WHERE job_id=?",
|
||||
(job_id,)
|
||||
)
|
||||
row = cur.fetchone()
|
||||
latest = int(row[0]) if row and row[0] is not None else None
|
||||
|
||||
if latest is None:
|
||||
# Nothing to clear
|
||||
conn.close()
|
||||
return json.dumps({"status": "ok", "cleared": 0}), 200, {"Content-Type": "application/json"}
|
||||
|
||||
# Delete all runs for older occurrences
|
||||
cur.execute(
|
||||
"DELETE FROM scheduled_job_runs WHERE job_id=? AND COALESCE(scheduled_ts, 0) < ?",
|
||||
(job_id, latest),
|
||||
)
|
||||
cleared = cur.rowcount or 0
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return json.dumps({"status": "ok"}), 200, {"Content-Type": "application/json"}
|
||||
return json.dumps({"status": "ok", "cleared": int(cleared), "kept_occurrence": latest}), 200, {"Content-Type": "application/json"}
|
||||
except Exception as e:
|
||||
return json.dumps({"error": str(e)}), 500, {"Content-Type": "application/json"}
|
||||
|
||||
|
||||
@@ -3,20 +3,7 @@
|
||||
"max_task_workers": 8,
|
||||
"config_file_watcher_interval": 2,
|
||||
"agent_id": "lab-operator-01-agent-66ba3ad3",
|
||||
"regions": {
|
||||
"node-1757304427610": {
|
||||
"x": 2864,
|
||||
"y": 258,
|
||||
"w": 497,
|
||||
"h": 442
|
||||
},
|
||||
"node-1758330030680": {
|
||||
"x": 3058,
|
||||
"y": 1016,
|
||||
"w": 565,
|
||||
"h": 126
|
||||
}
|
||||
},
|
||||
"regions": {},
|
||||
"agent_operating_system": "Windows 11 Pro 24H2 Build 26100.6584",
|
||||
"created": "2025-09-02 23:57:00"
|
||||
}
|
||||
Reference in New Issue
Block a user