Added Functional Job Scheduling for Scripts

This commit is contained in:
2025-09-23 02:32:30 -06:00
parent 1b46f2eed6
commit d5c86425be
4 changed files with 347 additions and 134 deletions

View File

@@ -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) --- // --- Scripts tree helpers (reuse approach from Quick_Job) ---
function buildScriptTree(scripts, folders) { function buildScriptTree(scripts, folders) {
const map = {}; const map = {};
@@ -112,7 +121,7 @@ function buildWorkflowTree(workflows, folders) {
function ComponentCard({ comp, onRemove }) { function ComponentCard({ comp, onRemove }) {
return ( 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={{ display: "flex", gap: 2 }}>
<Box sx={{ flex: 1 }}> <Box sx={{ flex: 1 }}>
<Typography variant="subtitle2" sx={{ color: "#e6edf3" }}> <Typography variant="subtitle2" sx={{ color: "#e6edf3" }}>
@@ -154,10 +163,8 @@ function ComponentCard({ comp, onRemove }) {
export default function CreateJob({ onCancel, onCreated, initialJob = null }) { export default function CreateJob({ onCancel, onCreated, initialJob = null }) {
const [tab, setTab] = useState(0); const [tab, setTab] = useState(0);
const [jobName, setJobName] = useState(""); const [jobName, setJobName] = useState("");
// Pre-seed with a placeholder component to keep flows working during UI build-out // Components the job will run: {type:'script'|'workflow', path, name, description}
const [components, setComponents] = useState([ const [components, setComponents] = useState([]);
{ type: "script", path: "demo/component", name: "Demonstration Component", description: "placeholder" }
]); // {type:'script'|'workflow', path, name, description}
const [targets, setTargets] = useState([]); // array of hostnames const [targets, setTargets] = useState([]); // array of hostnames
const [scheduleType, setScheduleType] = useState("immediately"); const [scheduleType, setScheduleType] = useState("immediately");
const [startDateTime, setStartDateTime] = useState(() => dayjs().add(5, "minute").second(0)); 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) { if (initialJob && initialJob.id) {
setJobName(initialJob.name || ""); setJobName(initialJob.name || "");
const comps = Array.isArray(initialJob.components) ? initialJob.components : []; 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 : []); setTargets(Array.isArray(initialJob.targets) ? initialJob.targets : []);
setScheduleType(initialJob.schedule_type || initialJob.schedule?.type || "immediately"); 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))); 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 addSelectedComponent = () => {
const map = compTab === "scripts" ? scriptMap : workflowMap; const map = compTab === "scripts" ? scriptMap : workflowMap;
const node = map[selectedNodeId]; const node = map[selectedNodeId];
if (!node || node.isFolder) return; if (!node || node.isFolder) return false;
if (compTab === "scripts" && node.script) { if (compTab === "scripts" && node.script) {
setComponents((prev) => [ setComponents((prev) => [
...prev, ...prev,
{ type: "script", path: node.path, name: node.fileName || node.label, description: node.path } { type: "script", path: node.path, name: node.fileName || node.label, description: node.path }
]); ]);
setSelectedNodeId("");
return true;
} else if (compTab === "workflows" && node.workflow) { } else if (compTab === "workflows" && node.workflow) {
setComponents((prev) => [ alert("Workflows within Scheduled Jobs are not supported yet");
...prev, return false;
{ type: "workflow", path: node.path, name: node.label, description: node.path }
]);
} }
setSelectedNodeId(""); setSelectedNodeId("");
return false;
}; };
const openAddTargets = async () => { const openAddTargets = async () => {
@@ -659,7 +667,8 @@ export default function CreateJob({ onCancel, onCreated, initialJob = null }) {
</Box> </Box>
<Box sx={{ mt: 2 }}> <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"> <Table size="small">
<TableHead> <TableHead>
<TableRow> <TableRow>
@@ -700,7 +709,11 @@ export default function CreateJob({ onCancel, onCreated, initialJob = null }) {
</Box> </Box>
<Box sx={{ mt: 2 }}> <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>
</Box> </Box>
)} )}
@@ -726,14 +739,15 @@ export default function CreateJob({ onCancel, onCreated, initialJob = null }) {
</Box> </Box>
{compTab === "scripts" && ( {compTab === "scripts" && (
<Paper sx={{ p: 1, bgcolor: "#1e1e1e", maxHeight: 400, overflow: "auto" }}> <Paper sx={{ p: 1, bgcolor: "#1e1e1e", maxHeight: 400, overflow: "auto" }}>
<SimpleTreeView onItemSelectionToggle={(_, id) => setSelectedNodeId(id)}> <SimpleTreeView onItemSelectionToggle={(_, id) => {
{scriptTree.length ? scriptTree.map((n) => ( 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}> <TreeItem key={n.id} itemId={n.id} label={n.label}>
{n.children?.map((c) => ( {n.children && n.children.length ? renderTreeNodes(n.children, scriptMap) : null}
<TreeItem key={c.id} itemId={c.id} label={c.label} />
))}
</TreeItem> </TreeItem>
)) : ( ))) : (
<Typography variant="body2" sx={{ color: "#888", p: 1 }}>No scripts found.</Typography> <Typography variant="body2" sx={{ color: "#888", p: 1 }}>No scripts found.</Typography>
)} )}
</SimpleTreeView> </SimpleTreeView>
@@ -741,14 +755,15 @@ export default function CreateJob({ onCancel, onCreated, initialJob = null }) {
)} )}
{compTab === "workflows" && ( {compTab === "workflows" && (
<Paper sx={{ p: 1, bgcolor: "#1e1e1e", maxHeight: 400, overflow: "auto" }}> <Paper sx={{ p: 1, bgcolor: "#1e1e1e", maxHeight: 400, overflow: "auto" }}>
<SimpleTreeView onItemSelectionToggle={(_, id) => setSelectedNodeId(id)}> <SimpleTreeView onItemSelectionToggle={(_, id) => {
{workflowTree.length ? workflowTree.map((n) => ( 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}> <TreeItem key={n.id} itemId={n.id} label={n.label}>
{n.children?.map((c) => ( {n.children && n.children.length ? renderTreeNodes(n.children, workflowMap) : null}
<TreeItem key={c.id} itemId={c.id} label={c.label} />
))}
</TreeItem> </TreeItem>
)) : ( ))) : (
<Typography variant="body2" sx={{ color: "#888", p: 1 }}>No workflows found.</Typography> <Typography variant="body2" sx={{ color: "#888", p: 1 }}>No workflows found.</Typography>
)} )}
</SimpleTreeView> </SimpleTreeView>
@@ -757,7 +772,7 @@ export default function CreateJob({ onCancel, onCreated, initialJob = null }) {
</DialogContent> </DialogContent>
<DialogActions> <DialogActions>
<Button onClick={() => setAddCompOpen(false)} sx={{ color: "#58a6ff" }}>Close</Button> <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} sx={{ color: "#58a6ff" }} disabled={!selectedNodeId}
>Add</Button> >Add</Button>
</DialogActions> </DialogActions>

View File

@@ -13,23 +13,28 @@ import {
TableRow, TableRow,
TableSortLabel, TableSortLabel,
Switch, Switch,
IconButton,
Menu,
MenuItem,
Dialog, Dialog,
DialogTitle, DialogTitle,
DialogContent, DialogContent,
DialogActions DialogActions,
Checkbox,
Popover,
TextField,
IconButton
} from "@mui/material"; } 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 }) { export default function ScheduledJobsList({ onCreateJob, onEditJob, refreshToken }) {
const [rows, setRows] = useState([]); const [rows, setRows] = useState([]);
const [orderBy, setOrderBy] = useState("name"); const [orderBy, setOrderBy] = useState("name");
const [order, setOrder] = useState("asc"); const [order, setOrder] = useState("asc");
const [menuAnchor, setMenuAnchor] = useState(null); const [selected, setSelected] = useState(new Set());
const [menuRow, setMenuRow] = useState(null); const [bulkDeleteOpen, setBulkDeleteOpen] = useState(false);
const [deleteOpen, setDeleteOpen] = 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 () => { const loadJobs = async () => {
try { 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 sorted = useMemo(() => {
const dir = order === "asc" ? 1 : -1; const dir = order === "asc" ? 1 : -1;
return [...rows].sort((a, b) => { return [...filtered].sort((a, b) => {
const A = a[orderBy] || ""; const A = a[orderBy] || "";
const B = b[orderBy] || ""; const B = b[orderBy] || "";
return String(A).localeCompare(String(B)) * dir; 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) => { const resultColor = (r) => {
if (r === 'Success') return '#00d18c'; 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))} {sections.map(({key,color}) => (s[key] ? <span key={key} style={styleSeg(color, seg(s[key]))} /> : null))}
</div> </div>
<div style={{ color: '#aaa', fontSize: 12, marginTop: 4 }}> <div style={{ color: '#aaa', fontSize: 12, marginTop: 4 }}>
{['success','running','failed','timed_out','expired','pending'] {(() => {
.filter(k => s[k]) const nonPendingKeys = ['success','running','failed','timed_out','expired'].filter(k => s[k]);
.map((k,i) => ( if (nonPendingKeys.length === 0 && s['pending']) {
<span key={k} style={{ marginRight: 10 }}> // Pending-only: show simple "Scheduled" label under the bar
<span style={{ display: 'inline-block', width: 8, height: 8, background: sections.find(x=>x.key===k).color, marginRight: 6 }} /> return <span>Scheduled</span>;
{s[k]} {k.replace('_',' ').replace(/^./, c=>c.toUpperCase())} }
</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>
</div> </div>
); );
@@ -156,39 +201,63 @@ export default function ScheduledJobsList({ onCreateJob, onEditJob, refreshToken
List of automation jobs with schedules, results, and actions. List of automation jobs with schedules, results, and actions.
</Typography> </Typography>
</Box> </Box>
<Button <Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
variant="outlined" <Button
size="small" variant="outlined"
sx={{ color: "#58a6ff", borderColor: "#58a6ff", textTransform: "none" }} size="small"
onClick={() => onCreateJob && onCreateJob()} disabled={!anySelected}
> sx={{ color: anySelected ? "#ff6666" : "#666", borderColor: anySelected ? "#ff6666" : "#444", textTransform: "none" }}
Create Job onClick={() => setBulkDeleteOpen(true)}
</Button> >
Delete Job
</Button>
<Button
variant="outlined"
size="small"
sx={{ color: "#58a6ff", borderColor: "#58a6ff", textTransform: "none" }}
onClick={() => onCreateJob && onCreateJob()}
>
Create Job
</Button>
</Box>
</Box> </Box>
<Table size="small" sx={{ minWidth: 900 }}> <Table size="small" sx={{ minWidth: 900 }}>
<TableHead> <TableHead>
<TableRow> <TableRow>
<TableCell width={40}>
<Checkbox
size="small"
checked={allSelected}
indeterminate={!allSelected && anySelected}
onChange={(e) => toggleSelectAll(e.target.checked)}
/>
</TableCell>
{[ {[
["name", "Name"], ["name", "Name"],
["scriptWorkflow", "Script / Workflow"], ["scriptWorkflow", "Script / Workflow"],
["target", "Target"], ["target", "Target"],
["occurrence", "Schedule Occurrence"], ["occurrence", "Recurrence"],
["lastRun", "Last Run"], ["lastRun", "Last Run"],
["nextRun", "Next Run"], ["nextRun", "Next Run"],
["result", "Result"], ["results", "Results"],
["results", "Results"], ["enabled", "Enabled"]
["enabled", "Enabled"],
["edit", "Edit Job"]
].map(([key, label]) => ( ].map(([key, label]) => (
<TableCell key={key} sortDirection={orderBy === key ? order : false}> <TableCell key={key} sortDirection={orderBy === key ? order : false}>
{key !== "edit" && key !== "results" ? ( {key !== "results" ? (
<TableSortLabel <Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
active={orderBy === key} <TableSortLabel
direction={orderBy === key ? order : "asc"} active={orderBy === key}
onClick={() => handleSort(key)} direction={orderBy === key ? order : "asc"}
> onClick={() => handleSort(key)}
{label} >
</TableSortLabel> {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 label
)} )}
@@ -199,26 +268,21 @@ export default function ScheduledJobsList({ onCreateJob, onEditJob, refreshToken
<TableBody> <TableBody>
{sorted.map((r, i) => ( {sorted.map((r, i) => (
<TableRow key={i} hover> <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.scriptWorkflow || "Demonstration Component"}</TableCell>
<TableCell>{r.target}</TableCell> <TableCell>{r.target}</TableCell>
<TableCell>{r.occurrence}</TableCell> <TableCell>{r.occurrence}</TableCell>
<TableCell>{r.lastRun}</TableCell> <TableCell>{r.lastRun}</TableCell>
<TableCell>{r.nextRun}</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> <TableCell>
<ResultsBar counts={r.resultsCounts} /> <ResultsBar counts={r.resultsCounts} />
</TableCell> </TableCell>
@@ -238,11 +302,6 @@ export default function ScheduledJobsList({ onCreateJob, onEditJob, refreshToken
size="small" size="small"
/> />
</TableCell> </TableCell>
<TableCell>
<IconButton size="small" onClick={(e) => { setMenuAnchor(e.currentTarget); setMenuRow(r); }} sx={{ color: "#58a6ff" }}>
<MoreHorizIcon fontSize="small" />
</IconButton>
</TableCell>
</TableRow> </TableRow>
))} ))}
{sorted.length === 0 && ( {sorted.length === 0 && (
@@ -254,45 +313,65 @@ export default function ScheduledJobsList({ onCreateJob, onEditJob, refreshToken
)} )}
</TableBody> </TableBody>
</Table> </Table>
<Menu <Dialog open={bulkDeleteOpen} onClose={() => setBulkDeleteOpen(false)}
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)}
PaperProps={{ sx: { bgcolor: '#121212', color: '#fff' } }} PaperProps={{ sx: { bgcolor: '#121212', color: '#fff' } }}
> >
<DialogTitle>Delete this Job?</DialogTitle> <DialogTitle>Are you sure you want to delete this job(s)?</DialogTitle>
<DialogContent>
<Typography variant="body2" sx={{ color: '#aaa' }}>
This will permanently remove the scheduled job from the list.
</Typography>
</DialogContent>
<DialogActions> <DialogActions>
<Button onClick={() => setDeleteOpen(false)} sx={{ color: '#58a6ff' }}>Cancel</Button> <Button onClick={() => setBulkDeleteOpen(false)} sx={{ color: '#58a6ff' }}>Cancel</Button>
<Button onClick={async () => { <Button onClick={async () => {
try { try {
if (menuRow?.id) { const ids = Array.from(selected);
await fetch(`/api/scheduled_jobs/${menuRow.id}`, { method: 'DELETE' }); await Promise.allSettled(ids.map(id => fetch(`/api/scheduled_jobs/${id}`, { method: 'DELETE' })));
setRows((prev) => prev.filter((r) => r.id !== menuRow.id)); setRows(prev => prev.filter(r => !selected.has(r.id)));
} setSelected(new Set());
} catch {} } catch {}
setDeleteOpen(false); setBulkDeleteOpen(false);
setMenuRow(null);
// Optionally reload to be safe
try { await loadJobs(); } catch {} try { await loadJobs(); } catch {}
}} variant="outlined" sx={{ color: '#58a6ff', borderColor: '#58a6ff' }}>Delete</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>
); );
} }

View File

@@ -130,6 +130,93 @@ class JobScheduler:
# Bind routes # Bind routes
self._register_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 ---------- # ---------- DB helpers ----------
def _conn(self): def _conn(self):
return sqlite3.connect(self.db_path) return sqlite3.connect(self.db_path)
@@ -310,7 +397,7 @@ class JobScheduler:
pass pass
try: try:
cur.execute( 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() jobs = cur.fetchall()
except Exception: except Exception:
@@ -328,7 +415,7 @@ class JobScheduler:
five_min = 300 five_min = 300
now_min = _now_minute() 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: try:
# Targets list for this job # Targets list for this job
try: try:
@@ -338,6 +425,22 @@ class JobScheduler:
targets = [str(t) for t in targets if isinstance(t, (str, int))] targets = [str(t) for t in targets if isinstance(t, (str, int))]
total_targets = len(targets) 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) exp_seconds = _parse_expiration(expiration)
# Determine current occurrence to work on # Determine current occurrence to work on
@@ -408,7 +511,7 @@ class JobScheduler:
except Exception: except Exception:
row = None row = None
if row: if row:
# Existing record if Running, timeout handled earlier; skip # Existing record - if Running, timeout handled earlier; skip
conn2.close() conn2.close()
continue continue
@@ -421,6 +524,12 @@ class JobScheduler:
(job_id, host, occ, ts_now, "Running", ts_now, ts_now), (job_id, host, occ, ts_now, "Running", ts_now, ts_now),
) )
conn2.commit() 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: except Exception:
pass pass
finally: finally:
@@ -877,13 +986,36 @@ class JobScheduler:
@app.route("/api/scheduled_jobs/<int:job_id>/runs", methods=["DELETE"]) @app.route("/api/scheduled_jobs/<int:job_id>/runs", methods=["DELETE"])
def api_scheduled_job_runs_clear(job_id: int): 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: try:
conn = self._conn() conn = self._conn()
cur = conn.cursor() 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.commit()
conn.close() 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: except Exception as e:
return json.dumps({"error": str(e)}), 500, {"Content-Type": "application/json"} return json.dumps({"error": str(e)}), 500, {"Content-Type": "application/json"}

View File

@@ -3,20 +3,7 @@
"max_task_workers": 8, "max_task_workers": 8,
"config_file_watcher_interval": 2, "config_file_watcher_interval": 2,
"agent_id": "lab-operator-01-agent-66ba3ad3", "agent_id": "lab-operator-01-agent-66ba3ad3",
"regions": { "regions": {},
"node-1757304427610": {
"x": 2864,
"y": 258,
"w": 497,
"h": 442
},
"node-1758330030680": {
"x": 3058,
"y": 1016,
"w": 565,
"h": 126
}
},
"agent_operating_system": "Windows 11 Pro 24H2 Build 26100.6584", "agent_operating_system": "Windows 11 Pro 24H2 Build 26100.6584",
"created": "2025-09-02 23:57:00" "created": "2025-09-02 23:57:00"
} }