mirror of
https://github.com/bunny-lab-io/Borealis.git
synced 2025-10-27 05:21:57 -06:00
Added Basic (Non-Functional) Job Scheduling
This commit is contained in:
@@ -12,7 +12,9 @@
|
|||||||
"@emotion/styled": "11.14.0",
|
"@emotion/styled": "11.14.0",
|
||||||
"@mui/icons-material": "7.0.2",
|
"@mui/icons-material": "7.0.2",
|
||||||
"@mui/material": "7.0.2",
|
"@mui/material": "7.0.2",
|
||||||
|
"@mui/x-date-pickers": "8.11.3",
|
||||||
"@mui/x-tree-view": "8.10.0",
|
"@mui/x-tree-view": "8.10.0",
|
||||||
|
"dayjs": "1.11.18",
|
||||||
"normalize.css": "8.0.1",
|
"normalize.css": "8.0.1",
|
||||||
"prismjs": "1.30.0",
|
"prismjs": "1.30.0",
|
||||||
"react-simple-code-editor": "0.13.1",
|
"react-simple-code-editor": "0.13.1",
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ import DeviceDetails from "./Devices/Device_Details";
|
|||||||
import WorkflowList from "./Workflows/Workflow_List";
|
import WorkflowList from "./Workflows/Workflow_List";
|
||||||
import ScriptEditor from "./Scripting/Script_Editor";
|
import ScriptEditor from "./Scripting/Script_Editor";
|
||||||
import ScheduledJobsList from "./Scheduling/Scheduled_Jobs_List";
|
import ScheduledJobsList from "./Scheduling/Scheduled_Jobs_List";
|
||||||
|
import CreateJob from "./Scheduling/Create_Job.jsx";
|
||||||
import UserManagement from "./Admin/User_Management.jsx";
|
import UserManagement from "./Admin/User_Management.jsx";
|
||||||
import ServerInfo from "./Admin/Server_Info.jsx";
|
import ServerInfo from "./Admin/Server_Info.jsx";
|
||||||
|
|
||||||
@@ -100,6 +101,8 @@ export default function App() {
|
|||||||
const fileInputRef = useRef(null);
|
const fileInputRef = useRef(null);
|
||||||
const [user, setUser] = useState(null);
|
const [user, setUser] = useState(null);
|
||||||
const [userRole, setUserRole] = useState(null);
|
const [userRole, setUserRole] = useState(null);
|
||||||
|
const [editingJob, setEditingJob] = useState(null);
|
||||||
|
const [jobsRefreshToken, setJobsRefreshToken] = useState(0);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const session = localStorage.getItem("borealis_session");
|
const session = localStorage.getItem("borealis_session");
|
||||||
@@ -348,7 +351,22 @@ export default function App() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
case "jobs":
|
case "jobs":
|
||||||
return <ScheduledJobsList />;
|
return (
|
||||||
|
<ScheduledJobsList
|
||||||
|
onCreateJob={() => { setEditingJob(null); setCurrentPage("create_job"); }}
|
||||||
|
onEditJob={(job) => { setEditingJob(job); setCurrentPage("create_job"); }}
|
||||||
|
refreshToken={jobsRefreshToken}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
case "create_job":
|
||||||
|
return (
|
||||||
|
<CreateJob
|
||||||
|
initialJob={editingJob}
|
||||||
|
onCancel={() => { setCurrentPage("jobs"); setEditingJob(null); }}
|
||||||
|
onCreated={() => { setCurrentPage("jobs"); setEditingJob(null); setJobsRefreshToken(Date.now()); }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
case "workflows":
|
case "workflows":
|
||||||
return (
|
return (
|
||||||
|
|||||||
613
Data/Server/WebUI/src/Scheduling/Create_Job.jsx
Normal file
613
Data/Server/WebUI/src/Scheduling/Create_Job.jsx
Normal file
@@ -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 (
|
||||||
|
<Box sx={{
|
||||||
|
mt: 2,
|
||||||
|
mb: 1,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between"
|
||||||
|
}}>
|
||||||
|
<Typography variant="subtitle1" sx={{ color: "#7db7ff" }}>{title}</Typography>
|
||||||
|
{action || null}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 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 (
|
||||||
|
<Paper sx={{ bgcolor: "#232323", border: "1px solid #333", p: 1, mb: 1 }}>
|
||||||
|
<Box sx={{ display: "flex", gap: 2 }}>
|
||||||
|
<Box sx={{ flex: 1 }}>
|
||||||
|
<Typography variant="subtitle2" sx={{ color: "#e6edf3" }}>
|
||||||
|
{comp.type === "script" ? comp.name : comp.name}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" sx={{ color: "#aaa" }}>
|
||||||
|
{comp.description || (comp.type === "script" ? comp.path : comp.path)}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Divider orientation="vertical" flexItem sx={{ borderColor: "#333" }} />
|
||||||
|
<Box sx={{ flex: 1 }}>
|
||||||
|
<Typography variant="subtitle2" sx={{ color: "#ccc", mb: 1 }}>Variables (placeholder)</Typography>
|
||||||
|
<FormControlLabel control={<Checkbox size="small" />} label={<Typography variant="body2">Example toggle</Typography>} />
|
||||||
|
<Box sx={{ display: "flex", gap: 1, mt: 1 }}>
|
||||||
|
<TextField size="small" label="Param A" variant="outlined" fullWidth
|
||||||
|
InputLabelProps={{ shrink: true }}
|
||||||
|
sx={{
|
||||||
|
"& .MuiOutlinedInput-root": { bgcolor: "#1b1b1b" },
|
||||||
|
"& .MuiInputLabel-root": { color: "#aaa" },
|
||||||
|
"& .MuiInputBase-input": { color: "#e6edf3" }
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Select size="small" value={"install"} sx={{ minWidth: 160 }}>
|
||||||
|
<MenuItem value="install">Install/Update existing installation</MenuItem>
|
||||||
|
<MenuItem value="uninstall">Uninstall</MenuItem>
|
||||||
|
</Select>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
<Box>
|
||||||
|
<IconButton onClick={onRemove} size="small" sx={{ color: "#ff6666" }}>
|
||||||
|
<DeleteIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<Paper sx={{ m: 2, p: 0, bgcolor: "#1e1e1e" }} elevation={2}>
|
||||||
|
<Box sx={{ p: 2, pb: 1 }}>
|
||||||
|
<Typography variant="h6" sx={{ color: "#58a6ff", mb: 0 }}>Create a Job</Typography>
|
||||||
|
<Typography variant="body2" sx={{ color: "#aaa" }}>
|
||||||
|
Configure advanced schedulable automation jobs for one or more devices.
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box sx={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
borderBottom: "1px solid #333",
|
||||||
|
px: 2
|
||||||
|
}}>
|
||||||
|
<Tabs value={tab} onChange={(_, v) => setTab(v)} sx={{ minHeight: 36 }}>
|
||||||
|
{tabDefs.map((t, i) => (
|
||||||
|
<Tab key={t.key} label={t.label} sx={{ minHeight: 36 }} />
|
||||||
|
))}
|
||||||
|
</Tabs>
|
||||||
|
<Box sx={{ display: "flex", alignItems: "center", gap: 1, mb: 1 }}>
|
||||||
|
<Button onClick={onCancel} sx={{ color: "#58a6ff", textTransform: "none" }}>Cancel</Button>
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
onClick={() => (isValid ? setConfirmOpen(true) : null)}
|
||||||
|
startIcon={<AddIcon />}
|
||||||
|
disabled={!isValid}
|
||||||
|
sx={{ color: isValid ? "#58a6ff" : "#666", borderColor: isValid ? "#58a6ff" : "#444", textTransform: "none" }}
|
||||||
|
>
|
||||||
|
{initialJob && initialJob.id ? "Save Changes" : "Create Job"}
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box sx={{ p: 2 }}>
|
||||||
|
{tab === 0 && (
|
||||||
|
<Box>
|
||||||
|
<SectionHeader title="Name" />
|
||||||
|
<TextField
|
||||||
|
fullWidth={false}
|
||||||
|
sx={{ width: { xs: "100%", sm: "60%", md: "50%" },
|
||||||
|
"& .MuiOutlinedInput-root": { bgcolor: "#1b1b1b" },
|
||||||
|
"& .MuiInputBase-input": { color: "#e6edf3" }
|
||||||
|
}}
|
||||||
|
placeholder="Example Job Name"
|
||||||
|
value={jobName}
|
||||||
|
onChange={(e) => setJobName(e.target.value)}
|
||||||
|
InputLabelProps={{ shrink: true }}
|
||||||
|
error={jobName.trim().length === 0}
|
||||||
|
helperText={jobName.trim().length === 0 ? "Job name is required" : ""}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tab === 1 && (
|
||||||
|
<Box>
|
||||||
|
<SectionHeader
|
||||||
|
title="Components"
|
||||||
|
action={(
|
||||||
|
<Button size="small" startIcon={<AddIcon />} onClick={openAddComponent}
|
||||||
|
sx={{ color: "#58a6ff", borderColor: "#58a6ff" }} variant="outlined">
|
||||||
|
Add Component
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{components.length === 0 && (
|
||||||
|
<Typography variant="body2" sx={{ color: "#888" }}>No components added yet.</Typography>
|
||||||
|
)}
|
||||||
|
{components.map((c, idx) => (
|
||||||
|
<ComponentCard key={`${c.type}-${c.path}-${idx}`} comp={c}
|
||||||
|
onRemove={() => setComponents((prev) => prev.filter((_, i) => i !== idx))}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{components.length === 0 && (
|
||||||
|
<Typography variant="caption" sx={{ color: "#ff6666" }}>At least one component is required.</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tab === 2 && (
|
||||||
|
<Box>
|
||||||
|
<SectionHeader
|
||||||
|
title="Targets"
|
||||||
|
action={(
|
||||||
|
<Button size="small" startIcon={<AddIcon />} onClick={openAddTargets}
|
||||||
|
sx={{ color: "#58a6ff", borderColor: "#58a6ff" }} variant="outlined">
|
||||||
|
Add Target
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Table size="small">
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell>Name</TableCell>
|
||||||
|
<TableCell>Status</TableCell>
|
||||||
|
<TableCell align="right">Actions</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{targets.map((h) => (
|
||||||
|
<TableRow key={h} hover>
|
||||||
|
<TableCell>{h}</TableCell>
|
||||||
|
<TableCell>—</TableCell>
|
||||||
|
<TableCell align="right">
|
||||||
|
<IconButton size="small" onClick={() => setTargets((prev) => prev.filter((x) => x !== h))} sx={{ color: "#ff6666" }}>
|
||||||
|
<DeleteIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
{targets.length === 0 && (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={3} sx={{ color: "#888" }}>No targets selected.</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
{targets.length === 0 && (
|
||||||
|
<Typography variant="caption" sx={{ color: "#ff6666" }}>At least one target is required.</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tab === 3 && (
|
||||||
|
<Box>
|
||||||
|
<SectionHeader title="Schedule" />
|
||||||
|
<Box sx={{ display: "flex", gap: 2, flexWrap: "wrap" }}>
|
||||||
|
<Box sx={{ minWidth: 260 }}>
|
||||||
|
<Typography variant="subtitle2" sx={{ color: "#ccc", mb: 0.5 }}>Recurrence</Typography>
|
||||||
|
<Select size="small" fullWidth value={scheduleType} onChange={(e) => setScheduleType(e.target.value)}>
|
||||||
|
<MenuItem value="immediately">Immediately</MenuItem>
|
||||||
|
<MenuItem value="once">At selected date and time</MenuItem>
|
||||||
|
<MenuItem value="daily">Daily</MenuItem>
|
||||||
|
<MenuItem value="weekly">Weekly</MenuItem>
|
||||||
|
<MenuItem value="monthly">Monthly</MenuItem>
|
||||||
|
<MenuItem value="yearly">Yearly</MenuItem>
|
||||||
|
</Select>
|
||||||
|
</Box>
|
||||||
|
{(scheduleType !== "immediately") && (
|
||||||
|
<Box sx={{ minWidth: 280 }}>
|
||||||
|
<Typography variant="subtitle2" sx={{ color: "#ccc", mb: 0.5 }}>Start date and execution time</Typography>
|
||||||
|
<LocalizationProvider dateAdapter={AdapterDayjs}>
|
||||||
|
<DateTimePicker
|
||||||
|
value={startDateTime}
|
||||||
|
onChange={(val) => setStartDateTime(val)}
|
||||||
|
slotProps={{ textField: { size: "small" } }}
|
||||||
|
/>
|
||||||
|
</LocalizationProvider>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Divider sx={{ my: 2, borderColor: "#333" }} />
|
||||||
|
<SectionHeader title="Duration" />
|
||||||
|
<FormControlLabel
|
||||||
|
control={<Checkbox checked={stopAfterEnabled} onChange={(e) => setStopAfterEnabled(e.target.checked)} />}
|
||||||
|
label={<Typography variant="body2">Stop running this job after</Typography>}
|
||||||
|
/>
|
||||||
|
<Box sx={{ mt: 1, minWidth: 260, width: 260 }}>
|
||||||
|
<Typography variant="subtitle2" sx={{ color: "#ccc", mb: 0.5 }}>Expiration</Typography>
|
||||||
|
<Select size="small" fullWidth value={expiration} onChange={(e) => setExpiration(e.target.value)}>
|
||||||
|
<MenuItem value="no_expire">Does not Expire</MenuItem>
|
||||||
|
<MenuItem value="30m">30 Minutes</MenuItem>
|
||||||
|
<MenuItem value="1h">1 Hour</MenuItem>
|
||||||
|
<MenuItem value="2h">2 Hours</MenuItem>
|
||||||
|
<MenuItem value="6h">6 Hours</MenuItem>
|
||||||
|
<MenuItem value="12h">12 Hours</MenuItem>
|
||||||
|
<MenuItem value="1d">1 Day</MenuItem>
|
||||||
|
<MenuItem value="2d">2 Days</MenuItem>
|
||||||
|
<MenuItem value="3d">3 Days</MenuItem>
|
||||||
|
</Select>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tab === 4 && (
|
||||||
|
<Box>
|
||||||
|
<SectionHeader title="Execution Context" />
|
||||||
|
<Select size="small" value={execContext} onChange={(e) => setExecContext(e.target.value)} sx={{ minWidth: 280 }}>
|
||||||
|
<MenuItem value="system">Run as SYSTEM Account</MenuItem>
|
||||||
|
<MenuItem value="current_user">Run as the Logged-In User</MenuItem>
|
||||||
|
</Select>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Bottom actions removed per design; actions live next to tabs. */}
|
||||||
|
|
||||||
|
{/* Add Component Dialog */}
|
||||||
|
<Dialog open={addCompOpen} onClose={() => setAddCompOpen(false)} fullWidth maxWidth="md"
|
||||||
|
PaperProps={{ sx: { bgcolor: "#121212", color: "#fff" } }}
|
||||||
|
>
|
||||||
|
<DialogTitle>Select a Script or Workflow</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<Box sx={{ display: "flex", gap: 2, mb: 1 }}>
|
||||||
|
<Button size="small" variant={compTab === "scripts" ? "outlined" : "text"} onClick={() => setCompTab("scripts")}
|
||||||
|
sx={{ textTransform: "none", color: "#58a6ff", borderColor: "#58a6ff" }}>
|
||||||
|
Scripts
|
||||||
|
</Button>
|
||||||
|
<Button size="small" variant={compTab === "workflows" ? "outlined" : "text"} onClick={() => setCompTab("workflows")}
|
||||||
|
sx={{ textTransform: "none", color: "#58a6ff", borderColor: "#58a6ff" }}>
|
||||||
|
Workflows
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
{compTab === "scripts" && (
|
||||||
|
<Paper sx={{ p: 1, bgcolor: "#1e1e1e", maxHeight: 400, overflow: "auto" }}>
|
||||||
|
<SimpleTreeView onItemSelectionToggle={(_, id) => 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} />
|
||||||
|
))}
|
||||||
|
</TreeItem>
|
||||||
|
)) : (
|
||||||
|
<Typography variant="body2" sx={{ color: "#888", p: 1 }}>No scripts found.</Typography>
|
||||||
|
)}
|
||||||
|
</SimpleTreeView>
|
||||||
|
</Paper>
|
||||||
|
)}
|
||||||
|
{compTab === "workflows" && (
|
||||||
|
<Paper sx={{ p: 1, bgcolor: "#1e1e1e", maxHeight: 400, overflow: "auto" }}>
|
||||||
|
<SimpleTreeView onItemSelectionToggle={(_, id) => 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} />
|
||||||
|
))}
|
||||||
|
</TreeItem>
|
||||||
|
)) : (
|
||||||
|
<Typography variant="body2" sx={{ color: "#888", p: 1 }}>No workflows found.</Typography>
|
||||||
|
)}
|
||||||
|
</SimpleTreeView>
|
||||||
|
</Paper>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={() => setAddCompOpen(false)} sx={{ color: "#58a6ff" }}>Close</Button>
|
||||||
|
<Button onClick={() => { addSelectedComponent(); setAddCompOpen(false); }}
|
||||||
|
sx={{ color: "#58a6ff" }} disabled={!selectedNodeId}
|
||||||
|
>Add</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Add Targets Dialog */}
|
||||||
|
<Dialog open={addTargetOpen} onClose={() => setAddTargetOpen(false)} fullWidth maxWidth="md"
|
||||||
|
PaperProps={{ sx: { bgcolor: "#121212", color: "#fff" } }}
|
||||||
|
>
|
||||||
|
<DialogTitle>Select Targets</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<Box sx={{ mb: 2, display: "flex", gap: 2 }}>
|
||||||
|
<TextField
|
||||||
|
size="small"
|
||||||
|
placeholder="Search devices..."
|
||||||
|
value={deviceSearch}
|
||||||
|
onChange={(e) => setDeviceSearch(e.target.value)}
|
||||||
|
sx={{ flex: 1, "& .MuiOutlinedInput-root": { bgcolor: "#1b1b1b" }, "& .MuiInputBase-input": { color: "#e6edf3" } }}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
<Table size="small">
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell width={40}></TableCell>
|
||||||
|
<TableCell>Name</TableCell>
|
||||||
|
<TableCell>Status</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{availableDevices
|
||||||
|
.filter((d) => d.display.toLowerCase().includes(deviceSearch.toLowerCase()))
|
||||||
|
.map((d) => (
|
||||||
|
<TableRow key={d.hostname} hover onClick={() => setSelectedTargets((prev) => ({ ...prev, [d.hostname]: !prev[d.hostname] }))}>
|
||||||
|
<TableCell>
|
||||||
|
<Checkbox size="small" checked={!!selectedTargets[d.hostname]}
|
||||||
|
onChange={(e) => setSelectedTargets((prev) => ({ ...prev, [d.hostname]: e.target.checked }))}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{d.display}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<span style={{ display: "inline-block", width: 10, height: 10, borderRadius: 10, background: d.online ? "#00d18c" : "#ff4f4f", marginRight: 8, verticalAlign: "middle" }} />
|
||||||
|
{d.online ? "Online" : "Offline"}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
{availableDevices.length === 0 && (
|
||||||
|
<TableRow><TableCell colSpan={3} sx={{ color: "#888" }}>No devices available.</TableCell></TableRow>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={() => setAddTargetOpen(false)} sx={{ color: "#58a6ff" }}>Cancel</Button>
|
||||||
|
<Button onClick={() => {
|
||||||
|
const chosen = Object.keys(selectedTargets).filter((h) => selectedTargets[h]);
|
||||||
|
setTargets((prev) => Array.from(new Set([...prev, ...chosen])));
|
||||||
|
setAddTargetOpen(false);
|
||||||
|
}} sx={{ color: "#58a6ff" }}>Add Selected</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Confirm Create Dialog */}
|
||||||
|
<Dialog open={confirmOpen} onClose={() => setConfirmOpen(false)}
|
||||||
|
PaperProps={{ sx: { bgcolor: "#121212", color: "#fff" } }}>
|
||||||
|
<DialogTitle sx={{ pb: 0 }}>{initialJob && initialJob.id ? "Are you sure you wish to save changes?" : "Are you sure you wish to create this Job?"}</DialogTitle>
|
||||||
|
<DialogActions sx={{ p: 2 }}>
|
||||||
|
<Button onClick={() => setConfirmOpen(false)} sx={{ color: "#58a6ff" }}>Cancel</Button>
|
||||||
|
<Button onClick={() => { setConfirmOpen(false); handleCreate(); }}
|
||||||
|
variant="outlined" sx={{ color: "#58a6ff", borderColor: "#58a6ff" }}>
|
||||||
|
Confirm
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -12,14 +12,60 @@ import {
|
|||||||
TableHead,
|
TableHead,
|
||||||
TableRow,
|
TableRow,
|
||||||
TableSortLabel,
|
TableSortLabel,
|
||||||
Switch
|
Switch,
|
||||||
|
IconButton,
|
||||||
|
Menu,
|
||||||
|
MenuItem,
|
||||||
|
Dialog,
|
||||||
|
DialogTitle,
|
||||||
|
DialogContent,
|
||||||
|
DialogActions
|
||||||
} from "@mui/material";
|
} 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 [rows, setRows] = useState([]);
|
||||||
const [orderBy, setOrderBy] = useState("name");
|
const [orderBy, setOrderBy] = useState("name");
|
||||||
const [order, setOrder] = useState("asc");
|
const [order, setOrder] = useState("asc");
|
||||||
|
const [menuAnchor, setMenuAnchor] = useState(null);
|
||||||
|
const [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) => {
|
const handleSort = (col) => {
|
||||||
if (orderBy === col) setOrder(order === "asc" ? "desc" : "asc");
|
if (orderBy === col) setOrder(order === "asc" ? "desc" : "asc");
|
||||||
@@ -43,13 +89,31 @@ export default function ScheduledJobsList() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Paper sx={{ m: 2, p: 0, bgcolor: "#1e1e1e" }} elevation={2}>
|
<Paper sx={{ m: 2, p: 0, bgcolor: "#1e1e1e" }} elevation={2}>
|
||||||
<Box sx={{ p: 2, pb: 1 }}>
|
<Box
|
||||||
<Typography variant="h6" sx={{ color: "#58a6ff", mb: 0 }}>
|
sx={{
|
||||||
Scheduled Jobs
|
p: 2,
|
||||||
</Typography>
|
pb: 1,
|
||||||
<Typography variant="body2" sx={{ color: "#aaa" }}>
|
display: "flex",
|
||||||
List of automation jobs with schedules, results, and actions.
|
justifyContent: "space-between",
|
||||||
</Typography>
|
alignItems: "center"
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box>
|
||||||
|
<Typography variant="h6" sx={{ color: "#58a6ff", mb: 0 }}>
|
||||||
|
Scheduled Jobs
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" sx={{ color: "#aaa" }}>
|
||||||
|
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>
|
</Box>
|
||||||
<Table size="small" sx={{ minWidth: 900 }}>
|
<Table size="small" sx={{ minWidth: 900 }}>
|
||||||
<TableHead>
|
<TableHead>
|
||||||
@@ -85,7 +149,7 @@ export default function ScheduledJobsList() {
|
|||||||
{sorted.map((r, i) => (
|
{sorted.map((r, i) => (
|
||||||
<TableRow key={i} hover>
|
<TableRow key={i} hover>
|
||||||
<TableCell>{r.name}</TableCell>
|
<TableCell>{r.name}</TableCell>
|
||||||
<TableCell>{r.scriptWorkflow}</TableCell>
|
<TableCell>{r.scriptWorkflow || "Demonstration Component"}</TableCell>
|
||||||
<TableCell>{r.target}</TableCell>
|
<TableCell>{r.target}</TableCell>
|
||||||
<TableCell>{r.occurrence}</TableCell>
|
<TableCell>{r.occurrence}</TableCell>
|
||||||
<TableCell>{r.lastRun}</TableCell>
|
<TableCell>{r.lastRun}</TableCell>
|
||||||
@@ -107,30 +171,23 @@ export default function ScheduledJobsList() {
|
|||||||
<TableCell>
|
<TableCell>
|
||||||
<Switch
|
<Switch
|
||||||
checked={r.enabled}
|
checked={r.enabled}
|
||||||
onChange={() => {
|
onChange={async () => {
|
||||||
setRows((prev) =>
|
try {
|
||||||
prev.map((job, idx) =>
|
await fetch(`/api/scheduled_jobs/${r.id}/toggle`, {
|
||||||
idx === i ? { ...job, enabled: !job.enabled } : job
|
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"
|
size="small"
|
||||||
/>
|
/>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Button
|
<IconButton size="small" onClick={(e) => { setMenuAnchor(e.currentTarget); setMenuRow(r); }} sx={{ color: "#58a6ff" }}>
|
||||||
variant="outlined"
|
<MoreHorizIcon fontSize="small" />
|
||||||
size="small"
|
</IconButton>
|
||||||
startIcon={<EditIcon />}
|
|
||||||
sx={{
|
|
||||||
color: "#58a6ff",
|
|
||||||
borderColor: "#58a6ff",
|
|
||||||
textTransform: "none"
|
|
||||||
}}
|
|
||||||
onClick={() => alert(`Edit job: ${r.name}`)}
|
|
||||||
>
|
|
||||||
Edit
|
|
||||||
</Button>
|
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
@@ -143,6 +200,45 @@ export default function ScheduledJobsList() {
|
|||||||
)}
|
)}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</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)}
|
||||||
|
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>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={() => setDeleteOpen(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));
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
setDeleteOpen(false);
|
||||||
|
setMenuRow(null);
|
||||||
|
// Optionally reload to be safe
|
||||||
|
try { await loadJobs(); } catch {}
|
||||||
|
}} variant="outlined" sx={{ color: '#58a6ff', borderColor: '#58a6ff' }}>Delete</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
</Paper>
|
</Paper>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.commit()
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
@@ -1465,6 +1485,238 @@ def get_agents():
|
|||||||
return jsonify(out)
|
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/<int:job_id>", 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/<int:job_id>", 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/<int:job_id>/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/<int:job_id>", 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"])
|
@app.route("/api/agent/details", methods=["POST"])
|
||||||
def save_agent_details():
|
def save_agent_details():
|
||||||
data = request.get_json(silent=True) or {}
|
data = request.get_json(silent=True) or {}
|
||||||
|
|||||||
Reference in New Issue
Block a user