diff --git a/Data/Server/WebUI/package.json b/Data/Server/WebUI/package.json index eed5c20..4251e7d 100644 --- a/Data/Server/WebUI/package.json +++ b/Data/Server/WebUI/package.json @@ -12,7 +12,9 @@ "@emotion/styled": "11.14.0", "@mui/icons-material": "7.0.2", "@mui/material": "7.0.2", + "@mui/x-date-pickers": "8.11.3", "@mui/x-tree-view": "8.10.0", + "dayjs": "1.11.18", "normalize.css": "8.0.1", "prismjs": "1.30.0", "react-simple-code-editor": "0.13.1", diff --git a/Data/Server/WebUI/src/App.jsx b/Data/Server/WebUI/src/App.jsx index e30d0d2..a219518 100644 --- a/Data/Server/WebUI/src/App.jsx +++ b/Data/Server/WebUI/src/App.jsx @@ -35,6 +35,7 @@ import DeviceDetails from "./Devices/Device_Details"; import WorkflowList from "./Workflows/Workflow_List"; import ScriptEditor from "./Scripting/Script_Editor"; import ScheduledJobsList from "./Scheduling/Scheduled_Jobs_List"; +import CreateJob from "./Scheduling/Create_Job.jsx"; import UserManagement from "./Admin/User_Management.jsx"; import ServerInfo from "./Admin/Server_Info.jsx"; @@ -100,6 +101,8 @@ export default function App() { const fileInputRef = useRef(null); const [user, setUser] = useState(null); const [userRole, setUserRole] = useState(null); + const [editingJob, setEditingJob] = useState(null); + const [jobsRefreshToken, setJobsRefreshToken] = useState(0); useEffect(() => { const session = localStorage.getItem("borealis_session"); @@ -348,7 +351,22 @@ export default function App() { ); case "jobs": - return ; + return ( + { setEditingJob(null); setCurrentPage("create_job"); }} + onEditJob={(job) => { setEditingJob(job); setCurrentPage("create_job"); }} + refreshToken={jobsRefreshToken} + /> + ); + + case "create_job": + return ( + { setCurrentPage("jobs"); setEditingJob(null); }} + onCreated={() => { setCurrentPage("jobs"); setEditingJob(null); setJobsRefreshToken(Date.now()); }} + /> + ); case "workflows": return ( diff --git a/Data/Server/WebUI/src/Scheduling/Create_Job.jsx b/Data/Server/WebUI/src/Scheduling/Create_Job.jsx new file mode 100644 index 0000000..b3055b7 --- /dev/null +++ b/Data/Server/WebUI/src/Scheduling/Create_Job.jsx @@ -0,0 +1,613 @@ +import React, { useEffect, useMemo, useState, useCallback } from "react"; +import { + Paper, + Box, + Typography, + Tabs, + Tab, + TextField, + Button, + IconButton, + Checkbox, + FormControlLabel, + Select, + MenuItem, + Divider, + Dialog, + DialogTitle, + DialogContent, + DialogActions, + Table, + TableHead, + TableRow, + TableCell, + TableBody +} from "@mui/material"; +import { Add as AddIcon, Delete as DeleteIcon } from "@mui/icons-material"; +import { SimpleTreeView, TreeItem } from "@mui/x-tree-view"; +import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider"; +import { DateTimePicker } from "@mui/x-date-pickers/DateTimePicker"; +import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs"; +import dayjs from "dayjs"; + +function SectionHeader({ title, action }) { + return ( + + {title} + {action || null} + + ); +} + +// --- Scripts tree helpers (reuse approach from Quick_Job) --- +function buildScriptTree(scripts, folders) { + const map = {}; + const rootNode = { id: "root_s", label: "Scripts", path: "", isFolder: true, children: [] }; + map[rootNode.id] = rootNode; + (folders || []).forEach((f) => { + const parts = (f || "").split("/"); + let children = rootNode.children; let parentPath = ""; + parts.forEach((part) => { + const path = parentPath ? `${parentPath}/${part}` : part; + let node = children.find((n) => n.id === path); + if (!node) { node = { id: path, label: part, path, isFolder: true, children: [] }; children.push(node); map[path] = node; } + children = node.children; parentPath = path; + }); + }); + (scripts || []).forEach((s) => { + const parts = (s.rel_path || "").split("/"); + let children = rootNode.children; let parentPath = ""; + parts.forEach((part, idx) => { + const path = parentPath ? `${parentPath}/${part}` : part; + const isFile = idx === parts.length - 1; + let node = children.find((n) => n.id === path); + if (!node) { + node = { id: path, label: isFile ? s.file_name : part, path, isFolder: !isFile, fileName: s.file_name, script: isFile ? s : null, children: [] }; + children.push(node); map[path] = node; + } + if (!isFile) { children = node.children; parentPath = path; } + }); + }); + return { root: [rootNode], map }; +} + +// --- Workflows tree helpers (reuse approach from Workflow_List) --- +function buildWorkflowTree(workflows, folders) { + const map = {}; + const rootNode = { id: "root_w", label: "Workflows", path: "", isFolder: true, children: [] }; + map[rootNode.id] = rootNode; + (folders || []).forEach((f) => { + const parts = (f || "").split("/"); + let children = rootNode.children; let parentPath = ""; + parts.forEach((part) => { + const path = parentPath ? `${parentPath}/${part}` : part; + let node = children.find((n) => n.id === path); + if (!node) { node = { id: path, label: part, path, isFolder: true, children: [] }; children.push(node); map[path] = node; } + children = node.children; parentPath = path; + }); + }); + (workflows || []).forEach((w) => { + const parts = (w.rel_path || "").split("/"); + let children = rootNode.children; let parentPath = ""; + parts.forEach((part, idx) => { + const path = parentPath ? `${parentPath}/${part}` : part; + const isFile = idx === parts.length - 1; + let node = children.find((n) => n.id === path); + if (!node) { + node = { id: path, label: isFile ? (w.tab_name?.trim() || w.file_name) : part, path, isFolder: !isFile, fileName: w.file_name, workflow: isFile ? w : null, children: [] }; + children.push(node); map[path] = node; + } + if (!isFile) { children = node.children; parentPath = path; } + }); + }); + return { root: [rootNode], map }; +} + +function ComponentCard({ comp, onRemove }) { + return ( + + + + + {comp.type === "script" ? comp.name : comp.name} + + + {comp.description || (comp.type === "script" ? comp.path : comp.path)} + + + + + Variables (placeholder) + } label={Example toggle} /> + + + + + + + + + + + + + ); +} + +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} + const [targets, setTargets] = useState([]); // array of hostnames + const [scheduleType, setScheduleType] = useState("immediately"); + const [startDateTime, setStartDateTime] = useState(() => dayjs().add(5, "minute")); + const [stopAfterEnabled, setStopAfterEnabled] = useState(false); + const [expiration, setExpiration] = useState("no_expire"); + const [execContext, setExecContext] = useState("system"); + + // dialogs state + const [addCompOpen, setAddCompOpen] = useState(false); + const [compTab, setCompTab] = useState("scripts"); + const [scriptTree, setScriptTree] = useState([]); const [scriptMap, setScriptMap] = useState({}); + const [workflowTree, setWorkflowTree] = useState([]); const [workflowMap, setWorkflowMap] = useState({}); + const [selectedNodeId, setSelectedNodeId] = useState(""); + + const [addTargetOpen, setAddTargetOpen] = useState(false); + const [availableDevices, setAvailableDevices] = useState([]); // [{hostname, display, online}] + const [selectedTargets, setSelectedTargets] = useState({}); // map hostname->bool + const [deviceSearch, setDeviceSearch] = useState(""); + + const isValid = useMemo(() => { + const base = jobName.trim().length > 0 && components.length > 0 && targets.length > 0; + if (!base) return false; + if (scheduleType !== "immediately") { + return !!startDateTime; + } + return true; + }, [jobName, components.length, targets.length, scheduleType, startDateTime]); + + const [confirmOpen, setConfirmOpen] = useState(false); + const editing = !!(initialJob && initialJob.id); + + useEffect(() => { + 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" }]); + 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) : (initialJob.schedule?.start ? dayjs(initialJob.schedule.start) : dayjs().add(5, "minute"))); + setStopAfterEnabled(Boolean(initialJob.duration_stop_enabled)); + setExpiration(initialJob.expiration || "no_expire"); + setExecContext(initialJob.execution_context || "system"); + } + }, [initialJob]); + + const openAddComponent = async () => { + setAddCompOpen(true); + try { + // scripts + const sResp = await fetch("/api/scripts/list"); + if (sResp.ok) { + const sData = await sResp.json(); + const { root, map } = buildScriptTree(sData.scripts || [], sData.folders || []); + setScriptTree(root); setScriptMap(map); + } else { setScriptTree([]); setScriptMap({}); } + } catch { setScriptTree([]); setScriptMap({}); } + try { + // workflows + const wResp = await fetch("/api/storage/load_workflows"); + if (wResp.ok) { + const wData = await wResp.json(); + const { root, map } = buildWorkflowTree(wData.workflows || [], wData.folders || []); + setWorkflowTree(root); setWorkflowMap(map); + } else { setWorkflowTree([]); setWorkflowMap({}); } + } catch { setWorkflowTree([]); setWorkflowMap({}); } + }; + + const addSelectedComponent = () => { + const map = compTab === "scripts" ? scriptMap : workflowMap; + const node = map[selectedNodeId]; + if (!node || node.isFolder) return; + if (compTab === "scripts" && node.script) { + setComponents((prev) => [ + ...prev, + { type: "script", path: node.path, name: node.fileName || node.label, description: node.path } + ]); + } else if (compTab === "workflows" && node.workflow) { + setComponents((prev) => [ + ...prev, + { type: "workflow", path: node.path, name: node.label, description: node.path } + ]); + } + setSelectedNodeId(""); + }; + + const openAddTargets = async () => { + setAddTargetOpen(true); + setSelectedTargets({}); + try { + const resp = await fetch("/api/agents"); + if (resp.ok) { + const data = await resp.json(); + const list = Object.values(data || {}).map((a) => ({ + hostname: a.hostname || a.agent_hostname || a.id || "unknown", + display: a.hostname || a.agent_hostname || a.id || "unknown", + online: !!a.collector_active + })); + list.sort((a, b) => a.display.localeCompare(b.display)); + setAvailableDevices(list); + } else { + setAvailableDevices([]); + } + } catch { + setAvailableDevices([]); + } + }; + + const handleCreate = async () => { + const payload = { + name: jobName, + components, + targets, + schedule: { type: scheduleType, start: scheduleType !== "immediately" ? (startDateTime?.toISOString?.() || startDateTime) : null }, + duration: { stopAfterEnabled, expiration }, + execution_context: execContext + }; + try { + const resp = await fetch(initialJob && initialJob.id ? `/api/scheduled_jobs/${initialJob.id}` : "/api/scheduled_jobs", { + method: initialJob && initialJob.id ? "PUT" : "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload) + }); + const data = await resp.json(); + if (!resp.ok) throw new Error(data.error || `HTTP ${resp.status}`); + onCreated && onCreated(data.job || payload); + onCancel && onCancel(); + } catch (err) { + alert(String(err.message || err)); + } + }; + + const tabDefs = useMemo(() => ([ + { key: "name", label: "Job Name" }, + { key: "components", label: "Scripts/Workflows" }, + { key: "targets", label: "Targets" }, + { key: "schedule", label: "Schedule" }, + { key: "context", label: "Execution Context" } + ]), []); + + return ( + + + Create a Job + + Configure advanced schedulable automation jobs for one or more devices. + + + + + setTab(v)} sx={{ minHeight: 36 }}> + {tabDefs.map((t, i) => ( + + ))} + + + + + + + + + {tab === 0 && ( + + + setJobName(e.target.value)} + InputLabelProps={{ shrink: true }} + error={jobName.trim().length === 0} + helperText={jobName.trim().length === 0 ? "Job name is required" : ""} + /> + + )} + + {tab === 1 && ( + + } onClick={openAddComponent} + sx={{ color: "#58a6ff", borderColor: "#58a6ff" }} variant="outlined"> + Add Component + + )} + /> + {components.length === 0 && ( + No components added yet. + )} + {components.map((c, idx) => ( + setComponents((prev) => prev.filter((_, i) => i !== idx))} + /> + ))} + {components.length === 0 && ( + At least one component is required. + )} + + )} + + {tab === 2 && ( + + } onClick={openAddTargets} + sx={{ color: "#58a6ff", borderColor: "#58a6ff" }} variant="outlined"> + Add Target + + )} + /> + + + + Name + Status + Actions + + + + {targets.map((h) => ( + + {h} + + + setTargets((prev) => prev.filter((x) => x !== h))} sx={{ color: "#ff6666" }}> + + + + + ))} + {targets.length === 0 && ( + + No targets selected. + + )} + +
+ {targets.length === 0 && ( + At least one target is required. + )} +
+ )} + + {tab === 3 && ( + + + + + Recurrence + + + {(scheduleType !== "immediately") && ( + + Start date and execution time + + setStartDateTime(val)} + slotProps={{ textField: { size: "small" } }} + /> + + + )} + + + + + setStopAfterEnabled(e.target.checked)} />} + label={Stop running this job after} + /> + + Expiration + + + + )} + + {tab === 4 && ( + + + + + )} +
+ + {/* Bottom actions removed per design; actions live next to tabs. */} + + {/* Add Component Dialog */} + setAddCompOpen(false)} fullWidth maxWidth="md" + PaperProps={{ sx: { bgcolor: "#121212", color: "#fff" } }} + > + Select a Script or Workflow + + + + + + {compTab === "scripts" && ( + + setSelectedNodeId(id)}> + {scriptTree.length ? scriptTree.map((n) => ( + + {n.children?.map((c) => ( + + ))} + + )) : ( + No scripts found. + )} + + + )} + {compTab === "workflows" && ( + + setSelectedNodeId(id)}> + {workflowTree.length ? workflowTree.map((n) => ( + + {n.children?.map((c) => ( + + ))} + + )) : ( + No workflows found. + )} + + + )} + + + + + + + + {/* Add Targets Dialog */} + setAddTargetOpen(false)} fullWidth maxWidth="md" + PaperProps={{ sx: { bgcolor: "#121212", color: "#fff" } }} + > + Select Targets + + + setDeviceSearch(e.target.value)} + sx={{ flex: 1, "& .MuiOutlinedInput-root": { bgcolor: "#1b1b1b" }, "& .MuiInputBase-input": { color: "#e6edf3" } }} + /> + + + + + + Name + Status + + + + {availableDevices + .filter((d) => d.display.toLowerCase().includes(deviceSearch.toLowerCase())) + .map((d) => ( + setSelectedTargets((prev) => ({ ...prev, [d.hostname]: !prev[d.hostname] }))}> + + setSelectedTargets((prev) => ({ ...prev, [d.hostname]: e.target.checked }))} + /> + + {d.display} + + + {d.online ? "Online" : "Offline"} + + + ))} + {availableDevices.length === 0 && ( + No devices available. + )} + +
+
+ + + + +
+ + {/* Confirm Create Dialog */} + setConfirmOpen(false)} + PaperProps={{ sx: { bgcolor: "#121212", color: "#fff" } }}> + {initialJob && initialJob.id ? "Are you sure you wish to save changes?" : "Are you sure you wish to create this Job?"} + + + + + +
+ ); +} diff --git a/Data/Server/WebUI/src/Scheduling/Scheduled_Jobs_List.jsx b/Data/Server/WebUI/src/Scheduling/Scheduled_Jobs_List.jsx index a92b5db..0145147 100644 --- a/Data/Server/WebUI/src/Scheduling/Scheduled_Jobs_List.jsx +++ b/Data/Server/WebUI/src/Scheduling/Scheduled_Jobs_List.jsx @@ -12,14 +12,60 @@ import { TableHead, TableRow, TableSortLabel, - Switch + Switch, + IconButton, + Menu, + MenuItem, + Dialog, + DialogTitle, + DialogContent, + DialogActions } from "@mui/material"; -import { Edit as EditIcon } from "@mui/icons-material"; +import { MoreHoriz as MoreHorizIcon } from "@mui/icons-material"; -export default function ScheduledJobsList() { +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 loadJobs = async () => { + try { + const resp = await fetch('/api/scheduled_jobs'); + const data = await resp.json(); + if (!resp.ok) throw new Error(data.error || `HTTP ${resp.status}`); + const rows = (data.jobs || []).map((j) => { + const compName = (Array.isArray(j.components) && j.components[0]?.name) || "Demonstration Component"; + const targetText = Array.isArray(j.targets) ? `${j.targets.length} device${j.targets.length!==1?'s':''}` : ''; + const occurrence = (j.schedule_type || 'immediately').replace(/^./, (c) => c.toUpperCase()); + const fmt = (ts) => { + if (!ts) return ''; + try { const d = new Date(Number(ts) * 1000); return d.toLocaleString(); } catch { return ''; } + }; + return { + id: j.id, + name: j.name, + scriptWorkflow: compName, + target: targetText, + occurrence, + lastRun: '', + nextRun: fmt(j.start_ts), + result: 'Success', + enabled: !!j.enabled, + raw: j + }; + }); + setRows(rows); + } catch (e) { + console.warn('Failed to load jobs', e); + setRows([]); + } + }; + + React.useEffect(() => { loadJobs(); }, []); + React.useEffect(() => { loadJobs(); }, [refreshToken]); const handleSort = (col) => { if (orderBy === col) setOrder(order === "asc" ? "desc" : "asc"); @@ -43,13 +89,31 @@ export default function ScheduledJobsList() { return ( - - - Scheduled Jobs - - - List of automation jobs with schedules, results, and actions. - + + + + Scheduled Jobs + + + List of automation jobs with schedules, results, and actions. + + + @@ -85,7 +149,7 @@ export default function ScheduledJobsList() { {sorted.map((r, i) => ( {r.name} - {r.scriptWorkflow} + {r.scriptWorkflow || "Demonstration Component"} {r.target} {r.occurrence} {r.lastRun} @@ -107,30 +171,23 @@ export default function ScheduledJobsList() { { - setRows((prev) => - prev.map((job, idx) => - idx === i ? { ...job, enabled: !job.enabled } : job - ) - ); + onChange={async () => { + try { + await fetch(`/api/scheduled_jobs/${r.id}/toggle`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ enabled: !r.enabled }) + }); + } catch {} + setRows((prev) => prev.map((job, idx) => idx === i ? { ...job, enabled: !job.enabled } : job)); }} size="small" /> - + { setMenuAnchor(e.currentTarget); setMenuRow(r); }} sx={{ color: "#58a6ff" }}> + + ))} @@ -143,6 +200,45 @@ export default function ScheduledJobsList() { )}
+ { 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)} + PaperProps={{ sx: { bgcolor: '#121212', color: '#fff' } }} + > + Delete this Job? + + + This will permanently remove the scheduled job from the list. + + + + + + +
); } diff --git a/Data/Server/server.py b/Data/Server/server.py index 0e44800..c638098 100644 --- a/Data/Server/server.py +++ b/Data/Server/server.py @@ -989,6 +989,26 @@ def init_db(): """ ) + conn.commit() + # Scheduled jobs table + cur.execute( + """ + CREATE TABLE IF NOT EXISTS scheduled_jobs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + components_json TEXT NOT NULL, + targets_json TEXT NOT NULL, + schedule_type TEXT NOT NULL, + start_ts INTEGER, + duration_stop_enabled INTEGER DEFAULT 0, + expiration TEXT, + execution_context TEXT NOT NULL, + enabled INTEGER DEFAULT 1, + created_at INTEGER, + updated_at INTEGER + ) + """ + ) conn.commit() conn.close() @@ -1465,6 +1485,238 @@ def get_agents(): return jsonify(out) +# --------------------------------------------- +# Scheduled Jobs API (basic CRUD/persistence only) +# --------------------------------------------- + +def _job_row_to_dict(r): + return { + "id": r[0], + "name": r[1], + "components": json.loads(r[2] or "[]"), + "targets": json.loads(r[3] or "[]"), + "schedule_type": r[4] or "immediately", + "start_ts": r[5], + "duration_stop_enabled": bool(r[6] or 0), + "expiration": r[7] or "no_expire", + "execution_context": r[8] or "system", + "enabled": bool(r[9] or 0), + "created_at": r[10] or 0, + "updated_at": r[11] or 0, + } + + +@app.route("/api/scheduled_jobs", methods=["GET"]) # list +def api_scheduled_jobs_list(): + try: + conn = _db_conn() + cur = conn.cursor() + cur.execute( + """ + SELECT id, name, components_json, targets_json, schedule_type, start_ts, + duration_stop_enabled, expiration, execution_context, enabled, created_at, updated_at + FROM scheduled_jobs + ORDER BY created_at DESC + """ + ) + rows = [ _job_row_to_dict(r) for r in cur.fetchall() ] + conn.close() + return jsonify({"jobs": rows}) + except Exception as e: + return jsonify({"error": str(e)}), 500 + + +@app.route("/api/scheduled_jobs", methods=["POST"]) # create +def api_scheduled_jobs_create(): + data = request.get_json(silent=True) or {} + name = (data.get("name") or "").strip() + components = data.get("components") or [] + targets = data.get("targets") or [] + schedule_type = (data.get("schedule", {}).get("type") or data.get("schedule_type") or "immediately").strip().lower() + start = data.get("schedule", {}).get("start") or data.get("start") or None + try: + start_ts = int(dayjs_to_ts(start)) if start else None + except Exception: + start_ts = None + duration_stop_enabled = int(bool((data.get("duration") or {}).get("stopAfterEnabled") or data.get("duration_stop_enabled"))) + expiration = (data.get("duration") or {}).get("expiration") or data.get("expiration") or "no_expire" + execution_context = (data.get("execution_context") or "system").strip().lower() + enabled = int(bool(data.get("enabled", True))) + if not name or not components or not targets: + return jsonify({"error": "name, components, targets required"}), 400 + now = _now_ts() + try: + conn = _db_conn() + cur = conn.cursor() + cur.execute( + """ + INSERT INTO scheduled_jobs + (name, components_json, targets_json, schedule_type, start_ts, duration_stop_enabled, expiration, execution_context, enabled, created_at, updated_at) + VALUES (?,?,?,?,?,?,?,?,?,?,?) + """, + ( + name, + json.dumps(components), + json.dumps(targets), + schedule_type, + start_ts, + duration_stop_enabled, + expiration, + execution_context, + enabled, + now, + now, + ), + ) + job_id = cur.lastrowid + conn.commit() + cur.execute( + """ + SELECT id, name, components_json, targets_json, schedule_type, start_ts, + duration_stop_enabled, expiration, execution_context, enabled, created_at, updated_at + FROM scheduled_jobs WHERE id=? + """, + (job_id,), + ) + row = cur.fetchone() + conn.close() + return jsonify({"job": _job_row_to_dict(row)}) + except Exception as e: + return jsonify({"error": str(e)}), 500 + + +@app.route("/api/scheduled_jobs/", methods=["GET"]) # get +def api_scheduled_jobs_get(job_id: int): + try: + conn = _db_conn() + cur = conn.cursor() + cur.execute( + """ + SELECT id, name, components_json, targets_json, schedule_type, start_ts, + duration_stop_enabled, expiration, execution_context, enabled, created_at, updated_at + FROM scheduled_jobs WHERE id=? + """, + (job_id,), + ) + row = cur.fetchone() + conn.close() + if not row: + return jsonify({"error": "not found"}), 404 + return jsonify({"job": _job_row_to_dict(row)}) + except Exception as e: + return jsonify({"error": str(e)}), 500 + + +@app.route("/api/scheduled_jobs/", methods=["PUT"]) # update +def api_scheduled_jobs_update(job_id: int): + data = request.get_json(silent=True) or {} + fields = {} + if "name" in data: + fields["name"] = (data.get("name") or "").strip() + if "components" in data: + fields["components_json"] = json.dumps(data.get("components") or []) + if "targets" in data: + fields["targets_json"] = json.dumps(data.get("targets") or []) + if "schedule" in data or "schedule_type" in data: + schedule_type = (data.get("schedule", {}).get("type") or data.get("schedule_type") or "immediately").strip().lower() + fields["schedule_type"] = schedule_type + start = data.get("schedule", {}).get("start") or data.get("start") or None + try: + fields["start_ts"] = int(dayjs_to_ts(start)) if start else None + except Exception: + fields["start_ts"] = None + if "duration" in data or "duration_stop_enabled" in data: + fields["duration_stop_enabled"] = int(bool((data.get("duration") or {}).get("stopAfterEnabled") or data.get("duration_stop_enabled"))) + if "expiration" in data or (data.get("duration") and "expiration" in data.get("duration")): + fields["expiration"] = (data.get("duration") or {}).get("expiration") or data.get("expiration") or "no_expire" + if "execution_context" in data: + fields["execution_context"] = (data.get("execution_context") or "system").strip().lower() + if "enabled" in data: + fields["enabled"] = int(bool(data.get("enabled"))) + if not fields: + return jsonify({"error": "no fields to update"}), 400 + try: + conn = _db_conn() + cur = conn.cursor() + sets = ", ".join([f"{k}=?" for k in fields.keys()]) + params = list(fields.values()) + [_now_ts(), job_id] + cur.execute(f"UPDATE scheduled_jobs SET {sets}, updated_at=? WHERE id=?", params) + if cur.rowcount == 0: + conn.close() + return jsonify({"error": "not found"}), 404 + conn.commit() + cur.execute( + """ + SELECT id, name, components_json, targets_json, schedule_type, start_ts, + duration_stop_enabled, expiration, execution_context, enabled, created_at, updated_at + FROM scheduled_jobs WHERE id=? + """, + (job_id,), + ) + row = cur.fetchone() + conn.close() + return jsonify({"job": _job_row_to_dict(row)}) + except Exception as e: + return jsonify({"error": str(e)}), 500 + + +@app.route("/api/scheduled_jobs//toggle", methods=["POST"]) # toggle enabled +def api_scheduled_jobs_toggle(job_id: int): + data = request.get_json(silent=True) or {} + enabled = int(bool(data.get("enabled", True))) + try: + conn = _db_conn() + cur = conn.cursor() + cur.execute("UPDATE scheduled_jobs SET enabled=?, updated_at=? WHERE id=?", (enabled, _now_ts(), job_id)) + if cur.rowcount == 0: + conn.close() + return jsonify({"error": "not found"}), 404 + conn.commit() + cur.execute( + "SELECT id, name, components_json, targets_json, schedule_type, start_ts, duration_stop_enabled, expiration, execution_context, enabled, created_at, updated_at FROM scheduled_jobs WHERE id=?", + (job_id,), + ) + row = cur.fetchone() + conn.close() + return jsonify({"job": _job_row_to_dict(row)}) + except Exception as e: + return jsonify({"error": str(e)}), 500 + + +@app.route("/api/scheduled_jobs/", methods=["DELETE"]) # delete +def api_scheduled_jobs_delete(job_id: int): + try: + conn = _db_conn() + cur = conn.cursor() + cur.execute("DELETE FROM scheduled_jobs WHERE id=?", (job_id,)) + deleted = cur.rowcount + conn.commit() + conn.close() + if deleted == 0: + return jsonify({"error": "not found"}), 404 + return jsonify({"status": "ok"}) + except Exception as e: + return jsonify({"error": str(e)}), 500 + + +def dayjs_to_ts(val): + """Convert various ISO-ish datetime strings to epoch seconds.""" + if val is None: + return None + if isinstance(val, (int, float)): + # assume seconds + return int(val) + try: + # Val may be ISO string; let Python parse + from datetime import datetime + # Ensure Z stripped or present + s = str(val).replace("Z", "+00:00") + dt = datetime.fromisoformat(s) + return int(dt.timestamp()) + except Exception: + return None + + @app.route("/api/agent/details", methods=["POST"]) def save_agent_details(): data = request.get_json(silent=True) or {}