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) ---
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>

View File

@@ -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>
);
}

View File

@@ -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"}

View File

@@ -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"
}