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 */}
+
+
+ {/* Add Targets Dialog */}
+
+
+ {/* Confirm Create Dialog */}
+
+
+ );
+}
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"
/>
- }
- sx={{
- color: "#58a6ff",
- borderColor: "#58a6ff",
- textTransform: "none"
- }}
- onClick={() => alert(`Edit job: ${r.name}`)}
- >
- Edit
-
+ { setMenuAnchor(e.currentTarget); setMenuRow(r); }} sx={{ color: "#58a6ff" }}>
+
+
))}
@@ -143,6 +200,45 @@ export default function ScheduledJobsList() {
)}
+
+
+
);
}
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 {}