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,
TableSortLabel
} 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 } />
Install/Update existing installation
Uninstall
);
}
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").second(0));
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);
// --- Job History (only when editing) ---
const [historyRows, setHistoryRows] = useState([]);
const [historyOrderBy, setHistoryOrderBy] = useState("started_ts");
const [historyOrder, setHistoryOrder] = useState("desc");
const fmtTs = useCallback((ts) => {
if (!ts) return '';
try {
const d = new Date(Number(ts) * 1000);
return d.toLocaleString(undefined, { year:'numeric', month:'2-digit', day:'2-digit', hour:'numeric', minute:'2-digit' });
} catch { return ''; }
}, []);
const loadHistory = useCallback(async () => {
if (!editing) return;
try {
const [runsResp, jobResp, devResp] = await Promise.all([
fetch(`/api/scheduled_jobs/${initialJob.id}/runs?days=30`),
fetch(`/api/scheduled_jobs/${initialJob.id}`),
fetch(`/api/scheduled_jobs/${initialJob.id}/devices`)
]);
const runs = await runsResp.json();
const job = await jobResp.json();
const dev = await devResp.json();
if (!runsResp.ok) throw new Error(runs.error || `HTTP ${runsResp.status}`);
if (!jobResp.ok) throw new Error(job.error || `HTTP ${jobResp.status}`);
if (!devResp.ok) throw new Error(dev.error || `HTTP ${devResp.status}`);
setHistoryRows(Array.isArray(runs.runs) ? runs.runs : []);
setJobSummary(job.job || {});
setDeviceRows(Array.isArray(dev.devices) ? dev.devices : []);
} catch {
setHistoryRows([]);
setJobSummary({});
setDeviceRows([]);
}
}, [editing, initialJob?.id]);
useEffect(() => {
if (!editing) return;
let t;
(async () => { try { await loadHistory(); } catch {} })();
t = setInterval(loadHistory, 10000);
return () => { if (t) clearInterval(t); };
}, [editing, loadHistory]);
const resultChip = (status) => {
const map = {
Success: { bg: '#00d18c', fg: '#000' },
Running: { bg: '#58a6ff', fg: '#000' },
Scheduled: { bg: '#999999', fg: '#fff' },
Expired: { bg: '#777777', fg: '#fff' },
Failed: { bg: '#ff4f4f', fg: '#fff' },
Warning: { bg: '#ff8c00', fg: '#000' }
};
const c = map[status] || { bg: '#aaa', fg: '#000' };
return (
{status || ''}
);
};
const sortedHistory = useMemo(() => {
const dir = historyOrder === 'asc' ? 1 : -1;
const key = historyOrderBy;
return [...historyRows].sort((a, b) => {
const A = a?.[key];
const B = b?.[key];
if (key === 'started_ts' || key === 'finished_ts' || key === 'scheduled_ts') {
return ((A || 0) - (B || 0)) * dir;
}
return String(A ?? '').localeCompare(String(B ?? '')) * dir;
});
}, [historyRows, historyOrderBy, historyOrder]);
const handleHistorySort = (col) => {
if (historyOrderBy === col) setHistoryOrder(historyOrder === 'asc' ? 'desc' : 'asc');
else { setHistoryOrderBy(col); setHistoryOrder('asc'); }
};
const renderHistory = () => (
handleHistorySort('scheduled_ts')}>
Scheduled
handleHistorySort('started_ts')}>
Started
handleHistorySort('finished_ts')}>
Finished
Status
{sortedHistory.map((r) => (
{fmtTs(r.scheduled_ts)}
{fmtTs(r.started_ts)}
{fmtTs(r.finished_ts)}
{resultChip(r.status)}
))}
{sortedHistory.length === 0 && (
No runs in the last 30 days.
)}
);
// --- Job Progress (summary) ---
const [jobSummary, setJobSummary] = useState({});
const sumCounts = (o, k) => Number((o?.result_counts||{})[k] || 0);
const counts = jobSummary?.result_counts || {};
const ProgressSummary = () => (
Job Progress
{[
['pending','Pending','#999999'],
['running','Running','#58a6ff'],
['success','Success','#00d18c'],
['failed','Failed','#ff4f4f'],
['expired','Expired','#777777'],
['timed_out','Timed Out','#b36ae2']
].map(([key,label,color]) => (
{label}: {Number((counts||{})[key] || 0)}
))}
);
// --- Devices breakdown ---
const [deviceRows, setDeviceRows] = useState([]);
const deviceSorted = useMemo(() => deviceRows, [deviceRows]);
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).second(0) : (initialJob.schedule?.start ? dayjs(initialJob.schedule.start).second(0) : dayjs().add(5, "minute").second(0)));
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" ? (() => { try { const d = startDateTime?.toDate?.() || new Date(startDateTime); d.setSeconds(0,0); return d.toISOString(); } catch { return 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(() => {
const base = [
{ key: "name", label: "Job Name" },
{ key: "components", label: "Scripts/Workflows" },
{ key: "targets", label: "Targets" },
{ key: "schedule", label: "Schedule" },
{ key: "context", label: "Execution Context" }
];
if (editing) base.push({ key: 'history', label: 'Job History' });
return base;
}, [editing]);
return (
Create a Job
Configure advanced schedulable automation jobs for one or more devices.
setTab(v)} sx={{ minHeight: 36 }}>
{tabDefs.map((t, i) => (
))}
Cancel
(isValid ? setConfirmOpen(true) : null)}
startIcon={ }
disabled={!isValid}
sx={{ color: isValid ? "#58a6ff" : "#666", borderColor: isValid ? "#58a6ff" : "#444", textTransform: "none" }}
>
{initialJob && initialJob.id ? "Save Changes" : "Create Job"}
{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
setScheduleType(e.target.value)}>
Immediately
At selected date and time
Daily
Weekly
Monthly
Yearly
{(scheduleType !== "immediately") && (
Start date and execution time
setStartDateTime(val?.second ? val.second(0) : val)}
views={['year','month','day','hours','minutes']}
format="YYYY-MM-DD hh:mm A"
slotProps={{ textField: { size: "small" } }}
/>
)}
setStopAfterEnabled(e.target.checked)} />}
label={Stop running this job after }
/>
Expiration
setExpiration(e.target.value)}>
Does not Expire
30 Minutes
1 Hour
2 Hours
6 Hours
12 Hours
1 Day
2 Days
3 Days
)}
{tab === 4 && (
setExecContext(e.target.value)} sx={{ minWidth: 280 }}>
Run as SYSTEM Account
Run as the Logged-In User
)}
{/* Job History tab (only when editing) */}
{editing && tab === tabDefs.findIndex(t => t.key === 'history') && (
Job History
{ try { await fetch(`/api/scheduled_jobs/${initialJob.id}/runs`, { method: 'DELETE' }); await loadHistory(); } catch {} }}
>
Clear Job History
Showing the last 30 days of runs.
Devices
Hostname
Status
Site
Ran On
Job Status
StdOut / StdErr
{deviceSorted.map((d, i) => (
{d.hostname}
{d.online ? 'Online' : 'Offline'}
{d.site || ''}
{fmtTs(d.ran_on)}
{resultChip(d.job_status)}
{d.has_stdout ? StdOut : null}
{d.has_stderr ? StdErr : null}
))}
{deviceSorted.length === 0 && (
No targets found for this job.
)}
{renderHistory()}
)}
{/* 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
setCompTab("scripts")}
sx={{ textTransform: "none", color: "#58a6ff", borderColor: "#58a6ff" }}>
Scripts
setCompTab("workflows")}
sx={{ textTransform: "none", color: "#58a6ff", borderColor: "#58a6ff" }}>
Workflows
{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.
)}
)}
setAddCompOpen(false)} sx={{ color: "#58a6ff" }}>Close
{ addSelectedComponent(); setAddCompOpen(false); }}
sx={{ color: "#58a6ff" }} disabled={!selectedNodeId}
>Add
{/* 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.
)}
setAddTargetOpen(false)} sx={{ color: "#58a6ff" }}>Cancel
{
const chosen = Object.keys(selectedTargets).filter((h) => selectedTargets[h]);
setTargets((prev) => Array.from(new Set([...prev, ...chosen])));
setAddTargetOpen(false);
}} sx={{ color: "#58a6ff" }}>Add Selected
{/* 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?"}
setConfirmOpen(false)} sx={{ color: "#58a6ff" }}>Cancel
{ setConfirmOpen(false); handleCreate(); }}
variant="outlined" sx={{ color: "#58a6ff", borderColor: "#58a6ff" }}>
Confirm
);
}