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} /> ); } 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) => ( ))} {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?.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 )} {tab === 4 && ( )} {/* Job History tab (only when editing) */} {editing && tab === tabDefs.findIndex(t => t.key === 'history') && ( 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 ? : null} {d.has_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 {compTab === "scripts" && ( setSelectedNodeId(id)}> {scriptTree.length ? scriptTree.map((n) => ( {n.children?.map((c) => ( ))} )) : ( No scripts found. )} )} {compTab === "workflows" && ( setSelectedNodeId(id)}> {workflowTree.length ? workflowTree.map((n) => ( {n.children?.map((c) => ( ))} )) : ( No workflows found. )} )} {/* Add Targets Dialog */} setAddTargetOpen(false)} fullWidth maxWidth="md" PaperProps={{ sx: { bgcolor: "#121212", color: "#fff" } }} > Select Targets setDeviceSearch(e.target.value)} sx={{ flex: 1, "& .MuiOutlinedInput-root": { bgcolor: "#1b1b1b" }, "& .MuiInputBase-input": { color: "#e6edf3" } }} /> Name Status {availableDevices .filter((d) => d.display.toLowerCase().includes(deviceSearch.toLowerCase())) .map((d) => ( setSelectedTargets((prev) => ({ ...prev, [d.hostname]: !prev[d.hostname] }))}> setSelectedTargets((prev) => ({ ...prev, [d.hostname]: e.target.checked }))} /> {d.display} {d.online ? "Online" : "Offline"} ))} {availableDevices.length === 0 && ( No devices available. )}
{/* Confirm Create Dialog */} setConfirmOpen(false)} PaperProps={{ sx: { bgcolor: "#121212", color: "#fff" } }}> {initialJob && initialJob.id ? "Are you sure you wish to save changes?" : "Are you sure you wish to create this Job?"}
); }