From d5c86425beec51def9ea62652482541ebef88683 Mon Sep 17 00:00:00 2001 From: Nicole Rappe Date: Tue, 23 Sep 2025 02:32:30 -0600 Subject: [PATCH] Added Functional Job Scheduling for Scripts --- .../WebUI/src/Scheduling/Create_Job.jsx | 67 +++-- .../src/Scheduling/Scheduled_Jobs_List.jsx | 257 ++++++++++++------ Data/Server/job_scheduler.py | 142 +++++++++- agent_settings_user.json | 15 +- 4 files changed, 347 insertions(+), 134 deletions(-) diff --git a/Data/Server/WebUI/src/Scheduling/Create_Job.jsx b/Data/Server/WebUI/src/Scheduling/Create_Job.jsx index b72c899..40e3665 100644 --- a/Data/Server/WebUI/src/Scheduling/Create_Job.jsx +++ b/Data/Server/WebUI/src/Scheduling/Create_Job.jsx @@ -46,6 +46,15 @@ function SectionHeader({ title, action }) { ); } +// Recursive renderer for both Scripts and Workflows trees +function renderTreeNodes(nodes = [], map = {}) { + return nodes.map((n) => ( + + {n.children && n.children.length ? renderTreeNodes(n.children, map) : null} + + )); +} + // --- 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 ( - + @@ -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 }) { - Devices + Devices + Devices targeted by this scheduled job. Individual job history is listed here. @@ -700,7 +709,11 @@ export default function CreateJob({ onCancel, onCreated, initialJob = null }) { - {renderHistory()} + Past Job History + Historical job history summaries. Detailed job history is not recorded. + + {renderHistory()} + )} @@ -726,14 +739,15 @@ export default function CreateJob({ onCancel, onCreated, initialJob = null }) { {compTab === "scripts" && ( - setSelectedNodeId(id)}> - {scriptTree.length ? scriptTree.map((n) => ( + { + const n = scriptMap[id]; + if (n && !n.isFolder) setSelectedNodeId(id); + }}> + {scriptTree.length ? (scriptTree.map((n) => ( - {n.children?.map((c) => ( - - ))} + {n.children && n.children.length ? renderTreeNodes(n.children, scriptMap) : null} - )) : ( + ))) : ( No scripts found. )} @@ -741,14 +755,15 @@ export default function CreateJob({ onCancel, onCreated, initialJob = null }) { )} {compTab === "workflows" && ( - setSelectedNodeId(id)}> - {workflowTree.length ? workflowTree.map((n) => ( + { + const n = workflowMap[id]; + if (n && !n.isFolder) setSelectedNodeId(id); + }}> + {workflowTree.length ? (workflowTree.map((n) => ( - {n.children?.map((c) => ( - - ))} + {n.children && n.children.length ? renderTreeNodes(n.children, workflowMap) : null} - )) : ( + ))) : ( No workflows found. )} @@ -757,7 +772,7 @@ export default function CreateJob({ onCancel, onCreated, initialJob = null }) { - diff --git a/Data/Server/WebUI/src/Scheduling/Scheduled_Jobs_List.jsx b/Data/Server/WebUI/src/Scheduling/Scheduled_Jobs_List.jsx index b4321a0..bc2b94e 100644 --- a/Data/Server/WebUI/src/Scheduling/Scheduled_Jobs_List.jsx +++ b/Data/Server/WebUI/src/Scheduling/Scheduled_Jobs_List.jsx @@ -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] ? : null))}
- {['success','running','failed','timed_out','expired','pending'] - .filter(k => s[k]) - .map((k,i) => ( - - x.key===k).color, marginRight: 6 }} /> - {s[k]} {k.replace('_',' ').replace(/^./, c=>c.toUpperCase())} - - ))} + {(() => { + const nonPendingKeys = ['success','running','failed','timed_out','expired'].filter(k => s[k]); + if (nonPendingKeys.length === 0 && s['pending']) { + // Pending-only: show simple "Scheduled" label under the bar + return Scheduled; + } + return ( + <> + {['success','running','failed','timed_out','expired','pending'] + .filter(k => s[k]) + .map((k) => ( + + x.key===k).color, marginRight: 6 }} /> + {s[k]} {k === 'pending' ? 'Scheduled' : k.replace('_',' ').replace(/^./, c=>c.toUpperCase())} + + ))} + + ); + })()}
); @@ -156,39 +201,63 @@ export default function ScheduledJobsList({ onCreateJob, onEditJob, refreshToken List of automation jobs with schedules, results, and actions. - + + + +
+ + toggleSelectAll(e.target.checked)} + /> + {[ ["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]) => ( - {key !== "edit" && key !== "results" ? ( - handleSort(key)} - > - {label} - + {key !== "results" ? ( + + handleSort(key)} + > + {label} + + {['name','occurrence','lastRun','nextRun'].includes(key) ? ( + + + + ) : null} + ) : ( label )} @@ -199,26 +268,21 @@ export default function ScheduledJobsList({ onCreateJob, onEditJob, refreshToken {sorted.map((r, i) => ( - {r.name} + + toggleSelect(r.id, e.target.checked)} /> + + + + {r.scriptWorkflow || "Demonstration Component"} {r.target} {r.occurrence} {r.lastRun} {r.nextRun} - - - {r.result} - @@ -238,11 +302,6 @@ export default function ScheduledJobsList({ onCreateJob, onEditJob, refreshToken size="small" /> - - { setMenuAnchor(e.currentTarget); setMenuRow(r); }} sx={{ color: "#58a6ff" }}> - - - ))} {sorted.length === 0 && ( @@ -254,45 +313,65 @@ export default function ScheduledJobsList({ onCreateJob, onEditJob, refreshToken )}
- { setMenuAnchor(null); setMenuRow(null); }} - PaperProps={{ sx: { bgcolor: "#1e1e1e", color: "#fff", fontSize: "13px" } }} - > - { - const job = menuRow?.raw; - setMenuAnchor(null); setMenuRow(null); - if (job && onEditJob) onEditJob(job); - }}>Edit - { setMenuAnchor(null); setDeleteOpen(true); }}>Delete - - - setDeleteOpen(false)} + setBulkDeleteOpen(false)} PaperProps={{ sx: { bgcolor: '#121212', color: '#fff' } }} > - Delete this Job? - - - This will permanently remove the scheduled job from the list. - - + Are you sure you want to delete this job(s)? - + + }} variant="outlined" sx={{ color: '#58a6ff', borderColor: '#58a6ff' }}>Confirm + + {/* Column filter popover */} + + {filterAnchor && ( + + { if (e.key === 'Escape') closeFilter(); }} + sx={{ + input: { color: '#fff' }, + minWidth: 220, + '& .MuiOutlinedInput-root': { '& fieldset': { borderColor: '#555' }, '&:hover fieldset': { borderColor: '#888' } } + }} + /> + + + )} +
); } diff --git a/Data/Server/job_scheduler.py b/Data/Server/job_scheduler.py index ecde533..6fd2a4c 100644 --- a/Data/Server/job_scheduler.py +++ b/Data/Server/job_scheduler.py @@ -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//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"} diff --git a/agent_settings_user.json b/agent_settings_user.json index 403c0de..cd3a4d3 100644 --- a/agent_settings_user.json +++ b/agent_settings_user.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" } \ No newline at end of file