diff --git a/Data/Server/WebUI/src/Devices/Device_Details.jsx b/Data/Server/WebUI/src/Devices/Device_Details.jsx index 10fe5b1..a85b22f 100644 --- a/Data/Server/WebUI/src/Devices/Device_Details.jsx +++ b/Data/Server/WebUI/src/Devices/Device_Details.jsx @@ -58,6 +58,7 @@ export default function DeviceDetails({ device, onBack }) { const [quickJobOpen, setQuickJobOpen] = useState(false); const [menuAnchor, setMenuAnchor] = useState(null); const [clearDialogOpen, setClearDialogOpen] = useState(false); + const [assemblyNameMap, setAssemblyNameMap] = useState({}); // Snapshotted status for the lifetime of this page const [lockedStatus, setLockedStatus] = useState(() => { // Prefer status provided by the device list row if available @@ -69,6 +70,68 @@ export default function DeviceDetails({ device, onBack }) { return now - tsSec <= 300 ? "Online" : "Offline"; }); + useEffect(() => { + let canceled = false; + const loadAssemblyNames = async () => { + const next = {}; + const storeName = (rawPath, rawName, prefix = "") => { + const name = typeof rawName === "string" ? rawName.trim() : ""; + if (!name) return; + const normalizedPath = String(rawPath || "") + .replace(/\\/g, "/") + .replace(/^\/+/, "") + .trim(); + const keys = new Set(); + if (normalizedPath) { + keys.add(normalizedPath); + if (prefix) { + const prefixed = `${prefix}/${normalizedPath}`.replace(/\/+/g, "/"); + keys.add(prefixed); + } + } + const base = normalizedPath ? normalizedPath.split("/").pop() || "" : ""; + if (base) { + keys.add(base); + const dot = base.lastIndexOf("."); + if (dot > 0) { + keys.add(base.slice(0, dot)); + } + } + keys.forEach((key) => { + if (key && !next[key]) { + next[key] = name; + } + }); + }; + const ingest = async (island, prefix = "") => { + try { + const resp = await fetch(`/api/assembly/list?island=${island}`); + if (!resp.ok) return; + const data = await resp.json(); + const items = Array.isArray(data.items) ? data.items : []; + items.forEach((item) => { + if (!item || typeof item !== "object") return; + const rel = item.rel_path || item.path || item.file_name || item.playbook_path || ""; + const label = (item.name || item.tab_name || item.display_name || item.file_name || "").trim(); + storeName(rel, label, prefix); + }); + } catch { + // ignore failures; map remains partial + } + }; + await ingest("scripts", "Scripts"); + await ingest("workflows", "Workflows"); + await ingest("ansible", "Ansible_Playbooks"); + if (!canceled) { + setAssemblyNameMap(next); + } + }; + loadAssemblyNames(); + return () => { + canceled = true; + }; + }, []); + const statusFromHeartbeat = (tsSec, offlineAfter = 300) => { if (!tsSec) return "Offline"; const now = Date.now() / 1000; @@ -77,6 +140,21 @@ export default function DeviceDetails({ device, onBack }) { const statusColor = (s) => (s === "Online" ? "#00d18c" : "#ff4f4f"); + const resolveAssemblyName = useCallback((scriptName, scriptPath) => { + const normalized = String(scriptPath || "").replace(/\\/g, "/").trim(); + const base = normalized ? normalized.split("/").pop() || "" : ""; + const baseNoExt = base && base.includes(".") ? base.slice(0, base.lastIndexOf(".")) : base; + return ( + assemblyNameMap[normalized] || + (base ? assemblyNameMap[base] : "") || + (baseNoExt ? assemblyNameMap[baseNoExt] : "") || + scriptName || + base || + scriptPath || + "" + ); + }, [assemblyNameMap]); + const formatLastSeen = (tsSec, offlineAfter = 120) => { if (!tsSec) return "unknown"; const now = Date.now() / 1000; @@ -918,7 +996,8 @@ export default function DeviceDetails({ device, onBack }) { } }; - const handleViewOutput = async (row, which) => { + const handleViewOutput = useCallback(async (row, which) => { + if (!row || !row.id) return; try { const resp = await fetch(`/api/device/activity/job/${row.id}`); if (!resp.ok) throw new Error(`HTTP ${resp.status}`); @@ -928,13 +1007,14 @@ export default function DeviceDetails({ device, onBack }) { : ((data.script_path || "").toLowerCase().endsWith(".sh")) ? "bash" : ((data.script_path || "").toLowerCase().endsWith(".yml")) ? "yaml" : "powershell"; setOutputLang(lang); - setOutputTitle(`${which === 'stderr' ? 'StdErr' : 'StdOut'} - ${data.script_name}`); + const friendly = resolveAssemblyName(data.script_name, data.script_path); + setOutputTitle(`${which === 'stderr' ? 'StdErr' : 'StdOut'} - ${friendly}`); setOutputContent(which === 'stderr' ? (data.stderr || "") : (data.stdout || "")); setOutputOpen(true); } catch (e) { console.warn("Failed to load output", e); } - }; + }, [resolveAssemblyName]); const handleHistorySort = (col) => { if (historyOrderBy === col) setHistoryOrder(historyOrder === "asc" ? "desc" : "asc"); @@ -944,15 +1024,23 @@ export default function DeviceDetails({ device, onBack }) { } }; + const historyDisplayRows = useMemo(() => { + return (historyRows || []).map((row) => ({ + ...row, + script_display_name: resolveAssemblyName(row.script_name, row.script_path), + })); + }, [historyRows, resolveAssemblyName]); + const sortedHistory = useMemo(() => { const dir = historyOrder === "asc" ? 1 : -1; - return [...historyRows].sort((a, b) => { - const A = a[historyOrderBy]; - const B = b[historyOrderBy]; - if (historyOrderBy === "ran_at") return ((A || 0) - (B || 0)) * dir; + const key = historyOrderBy === "script_name" ? "script_display_name" : historyOrderBy; + return [...historyDisplayRows].sort((a, b) => { + const A = a[key]; + const B = b[key]; + if (key === "ran_at") return ((A || 0) - (B || 0)) * dir; return String(A ?? "").localeCompare(String(B ?? "")) * dir; }); - }, [historyRows, historyOrderBy, historyOrder]); + }, [historyDisplayRows, historyOrderBy, historyOrder]); const renderHistory = () => ( @@ -992,7 +1080,7 @@ export default function DeviceDetails({ device, onBack }) { {sortedHistory.map((r) => ( {(r.script_type || '').toLowerCase() === 'ansible' ? 'Ansible Playbook' : 'Script'} - {r.script_name} + {r.script_display_name || r.script_name} {formatTimestamp(r.ran_at)} { + event?.preventDefault(); + event?.stopPropagation(); + onClick && onClick(); + }, [onClick]); + return ( + + + + + + + {Icon ? : null} + + {`${displayCount} ${label || ""}`} + + + + ); +} function SectionHeader({ title, action }) { return ( @@ -322,6 +412,7 @@ function ComponentCard({ comp, onRemove, onVariableChange, errors = {} }) { export default function CreateJob({ onCancel, onCreated, initialJob = null }) { const [tab, setTab] = useState(0); const [jobName, setJobName] = useState(""); + const [pageTitleJobName, setPageTitleJobName] = useState(""); // Components the job will run: {type:'script'|'workflow', path, name, description} const [components, setComponents] = useState([]); const [targets, setTargets] = useState([]); // array of hostnames @@ -344,12 +435,264 @@ export default function CreateJob({ onCancel, onCreated, initialJob = null }) { const [selectedTargets, setSelectedTargets] = useState({}); // map hostname->bool const [deviceSearch, setDeviceSearch] = useState(""); const [componentVarErrors, setComponentVarErrors] = useState({}); + const [deviceRows, setDeviceRows] = useState([]); + const [deviceStatusFilter, setDeviceStatusFilter] = useState(null); + const [deviceOrderBy, setDeviceOrderBy] = useState("hostname"); + const [deviceOrder, setDeviceOrder] = useState("asc"); + const [deviceFilters, setDeviceFilters] = useState({}); + const [filterAnchorEl, setFilterAnchorEl] = useState(null); + const [activeFilterColumn, setActiveFilterColumn] = useState(null); + const [pendingFilterValue, setPendingFilterValue] = useState(""); const generateLocalId = useCallback( () => `${Date.now()}_${Math.random().toString(36).slice(2, 8)}`, [] ); + const getDefaultFilterValue = useCallback((key) => (["online", "job_status", "output"].includes(key) ? "all" : ""), []); + + const isColumnFiltered = useCallback((key) => { + if (!deviceFilters || typeof deviceFilters !== "object") return false; + const value = deviceFilters[key]; + if (value == null) return false; + if (typeof value === "string") { + const trimmed = value.trim(); + if (!trimmed || trimmed === "all") return false; + return true; + } + return true; + }, [deviceFilters]); + + const openFilterMenu = useCallback((event, columnKey) => { + setActiveFilterColumn(columnKey); + setPendingFilterValue(deviceFilters[columnKey] ?? getDefaultFilterValue(columnKey)); + setFilterAnchorEl(event.currentTarget); + }, [deviceFilters, getDefaultFilterValue]); + + const closeFilterMenu = useCallback(() => { + setFilterAnchorEl(null); + setActiveFilterColumn(null); + }, []); + + const applyFilter = useCallback(() => { + if (!activeFilterColumn) { + closeFilterMenu(); + return; + } + const value = pendingFilterValue; + setDeviceFilters((prev) => { + const next = { ...(prev || {}) }; + if (!value || value === "all" || (typeof value === "string" && !value.trim())) { + delete next[activeFilterColumn]; + } else { + next[activeFilterColumn] = value; + } + return next; + }); + closeFilterMenu(); + }, [activeFilterColumn, pendingFilterValue, closeFilterMenu]); + + const clearFilter = useCallback(() => { + if (!activeFilterColumn) { + closeFilterMenu(); + return; + } + setDeviceFilters((prev) => { + const next = { ...(prev || {}) }; + delete next[activeFilterColumn]; + return next; + }); + setPendingFilterValue(getDefaultFilterValue(activeFilterColumn)); + closeFilterMenu(); + }, [activeFilterColumn, closeFilterMenu, getDefaultFilterValue]); + + const renderFilterControl = () => { + const columnKey = activeFilterColumn; + if (!columnKey) return null; + if (columnKey === "online") { + return ( + + ); + } + if (columnKey === "job_status") { + const options = ["success", "failed", "running", "pending", "expired", "timed out"]; + return ( + + ); + } + if (columnKey === "output") { + return ( + + ); + } + const placeholders = { + hostname: "Filter hostname", + site: "Filter site", + ran_on: "Filter date/time" + }; + const value = typeof pendingFilterValue === "string" ? pendingFilterValue : ""; + return ( + setPendingFilterValue(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + applyFilter(); + } + }} + /> + ); + }; + + const handleDeviceSort = useCallback((key) => { + setDeviceOrderBy((prevKey) => { + if (prevKey === key) { + setDeviceOrder((prevDir) => (prevDir === "asc" ? "desc" : "asc")); + return prevKey; + } + setDeviceOrder(key === "ran_on" ? "desc" : "asc"); + return key; + }); + }, []); + + 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 deviceFiltered = useMemo(() => { + const matchStatusFilter = (status, filterKey) => { + if (filterKey === "pending") return status === "pending" || status === "scheduled" || status === "queued" || status === ""; + if (filterKey === "running") return status === "running"; + if (filterKey === "success") return status === "success"; + if (filterKey === "failed") return status === "failed" || status === "failure" || status === "timed out" || status === "timed_out" || status === "warning"; + if (filterKey === "expired") return status === "expired"; + return true; + }; + + return deviceRows.filter((row) => { + const normalizedStatus = String(row?.job_status || "").trim().toLowerCase(); + if (deviceStatusFilter && !matchStatusFilter(normalizedStatus, deviceStatusFilter)) { + return false; + } + if (deviceFilters && typeof deviceFilters === "object") { + for (const [key, rawValue] of Object.entries(deviceFilters)) { + if (rawValue == null) continue; + if (typeof rawValue === "string") { + const trimmed = rawValue.trim(); + if (!trimmed || trimmed === "all") continue; + } + if (key === "hostname") { + const expected = String(rawValue || "").toLowerCase(); + if (!String(row?.hostname || "").toLowerCase().includes(expected)) return false; + } else if (key === "online") { + if (rawValue === "online" && !row?.online) return false; + if (rawValue === "offline" && row?.online) return false; + } else if (key === "site") { + const expected = String(rawValue || "").toLowerCase(); + if (!String(row?.site || "").toLowerCase().includes(expected)) return false; + } else if (key === "ran_on") { + const expected = String(rawValue || "").toLowerCase(); + const formatted = fmtTs(row?.ran_on).toLowerCase(); + if (!formatted.includes(expected)) return false; + } else if (key === "job_status") { + const expected = String(rawValue || "").toLowerCase(); + if (!normalizedStatus.includes(expected)) return false; + } else if (key === "output") { + if (rawValue === "stdout" && !row?.has_stdout) return false; + if (rawValue === "stderr" && !row?.has_stderr) return false; + if (rawValue === "both" && (!row?.has_stdout || !row?.has_stderr)) return false; + if (rawValue === "none" && (row?.has_stdout || row?.has_stderr)) return false; + } + } + } + return true; + }); + }, [deviceRows, deviceStatusFilter, deviceFilters, fmtTs]); + + const deviceSorted = useMemo(() => { + const arr = [...deviceFiltered]; + const dir = deviceOrder === "asc" ? 1 : -1; + arr.sort((a, b) => { + let delta = 0; + switch (deviceOrderBy) { + case "hostname": + delta = String(a?.hostname || "").localeCompare(String(b?.hostname || "")); + break; + case "online": + delta = Number(a?.online ? 1 : 0) - Number(b?.online ? 1 : 0); + break; + case "site": + delta = String(a?.site || "").localeCompare(String(b?.site || "")); + break; + case "ran_on": + delta = Number(a?.ran_on || 0) - Number(b?.ran_on || 0); + break; + case "job_status": + delta = String(a?.job_status || "").localeCompare(String(b?.job_status || "")); + break; + case "output": { + const score = (row) => (row?.has_stdout ? 2 : 0) + (row?.has_stderr ? 1 : 0); + delta = score(a) - score(b); + break; + } + default: + delta = 0; + } + if (delta === 0) { + delta = String(a?.hostname || "").localeCompare(String(b?.hostname || "")); + } + return delta * dir; + }); + return arr; + }, [deviceFiltered, deviceOrder, deviceOrderBy]); + const normalizeComponentPath = useCallback((type, rawPath) => { const trimmed = (rawPath || "").replace(/\\/g, "/").replace(/^\/+/, "").trim(); if (!trimmed) return ""; @@ -497,14 +840,12 @@ export default function CreateJob({ onCancel, onCreated, initialJob = null }) { 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 activityCacheRef = useRef(new Map()); + const [outputOpen, setOutputOpen] = useState(false); + const [outputTitle, setOutputTitle] = useState(""); + const [outputSections, setOutputSections] = useState([]); + const [outputLoading, setOutputLoading] = useState(false); + const [outputError, setOutputError] = useState(""); const loadHistory = useCallback(async () => { if (!editing) return; @@ -522,7 +863,11 @@ export default function CreateJob({ onCancel, onCreated, initialJob = null }) { 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 : []); + const devices = Array.isArray(dev.devices) ? dev.devices.map((device) => ({ + ...device, + activities: Array.isArray(device.activities) ? device.activities : [], + })) : []; + setDeviceRows(devices); } catch { setHistoryRows([]); setJobSummary({}); @@ -555,18 +900,68 @@ export default function CreateJob({ onCancel, onCreated, initialJob = null }) { ); }; + const aggregatedHistory = useMemo(() => { + if (!Array.isArray(historyRows) || historyRows.length === 0) return []; + const map = new Map(); + historyRows.forEach((row) => { + const key = row?.scheduled_ts || row?.started_ts || row?.finished_ts || row?.id; + if (!key) return; + const strKey = String(key); + const existing = map.get(strKey) || { + key: strKey, + scheduled_ts: row?.scheduled_ts || null, + started_ts: null, + finished_ts: null, + statuses: new Set() + }; + if (!existing.scheduled_ts && row?.scheduled_ts) existing.scheduled_ts = row.scheduled_ts; + if (row?.started_ts) { + existing.started_ts = existing.started_ts == null ? row.started_ts : Math.min(existing.started_ts, row.started_ts); + } + if (row?.finished_ts) { + existing.finished_ts = existing.finished_ts == null ? row.finished_ts : Math.max(existing.finished_ts, row.finished_ts); + } + if (row?.status) existing.statuses.add(String(row.status)); + map.set(strKey, existing); + }); + const summaries = []; + map.forEach((entry) => { + const statuses = Array.from(entry.statuses).map((s) => String(s || "").trim().toLowerCase()).filter(Boolean); + if (!statuses.length) return; + const hasInFlight = statuses.some((s) => s === "running" || s === "pending" || s === "scheduled"); + if (hasInFlight) return; + const hasFailure = statuses.some((s) => ["failed", "failure", "expired", "timed out", "timed_out", "warning"].includes(s)); + const allSuccess = statuses.every((s) => s === "success"); + const statusLabel = hasFailure ? "Failed" : (allSuccess ? "Success" : "Failed"); + summaries.push({ + key: entry.key, + scheduled_ts: entry.scheduled_ts, + started_ts: entry.started_ts, + finished_ts: entry.finished_ts, + status: statusLabel + }); + }); + return summaries; + }, [historyRows]); + 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 [...aggregatedHistory].sort((a, b) => { + const getVal = (row) => { + if (key === 'scheduled_ts' || key === 'started_ts' || key === 'finished_ts') { + return Number(row?.[key] || 0); + } + return String(row?.[key] || ''); + }; + const A = getVal(a); + const B = getVal(b); + if (typeof A === 'number' && typeof B === 'number') { + return (A - B) * dir; } - return String(A ?? '').localeCompare(String(B ?? '')) * dir; + return String(A).localeCompare(String(B)) * dir; }); - }, [historyRows, historyOrderBy, historyOrder]); + }, [aggregatedHistory, historyOrderBy, historyOrder]); const handleHistorySort = (col) => { if (historyOrderBy === col) setHistoryOrder(historyOrder === 'asc' ? 'desc' : 'asc'); @@ -598,7 +993,7 @@ export default function CreateJob({ onCancel, onCreated, initialJob = null }) { {sortedHistory.map((r) => ( - + {fmtTs(r.scheduled_ts)} {fmtTs(r.started_ts)} {fmtTs(r.finished_ts)} @@ -617,39 +1012,294 @@ export default function CreateJob({ onCancel, onCreated, initialJob = null }) { // --- 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)} - - ))} + const deviceStatusCounts = useMemo(() => { + const base = { pending: 0, running: 0, success: 0, failed: 0, expired: 0 }; + deviceRows.forEach((row) => { + const normalized = String(row?.job_status || "").trim().toLowerCase(); + if (!normalized || normalized === "pending" || normalized === "scheduled" || normalized === "queued") { + base.pending += 1; + } else if (normalized === "running") { + base.running += 1; + } else if (normalized === "success") { + base.success += 1; + } else if (normalized === "expired") { + base.expired += 1; + } else if (normalized === "failed" || normalized === "failure" || normalized === "timed out" || normalized === "timed_out" || normalized === "warning") { + base.failed += 1; + } else { + base.pending += 1; + } + }); + return base; + }, [deviceRows]); + + const statusCounts = useMemo(() => { + const merged = { pending: 0, running: 0, success: 0, failed: 0, expired: 0 }; + Object.keys(merged).forEach((key) => { + const summaryVal = Number((counts || {})[key] ?? 0); + const fallback = deviceStatusCounts[key] ?? 0; + merged[key] = summaryVal > 0 ? summaryVal : fallback; + }); + return merged; + }, [counts, deviceStatusCounts]); + + const statusNodeTypes = useMemo(() => ({ statusNode: StatusNode }), []); + + const handleStatusNodeClick = useCallback((key) => { + setDeviceStatusFilter((prev) => (prev === key ? null : key)); + }, []); + + const statusNodes = useMemo(() => [ + { + id: "pending", + type: "statusNode", + position: { x: -420, y: 170 }, + data: { + label: STATUS_META.pending.label, + color: STATUS_META.pending.color, + count: statusCounts.pending, + Icon: STATUS_META.pending.Icon, + onClick: () => handleStatusNodeClick("pending"), + isActive: deviceStatusFilter === "pending" + }, + draggable: false, + selectable: false + }, + { + id: "running", + type: "statusNode", + position: { x: 0, y: 0 }, + data: { + label: STATUS_META.running.label, + color: STATUS_META.running.color, + count: statusCounts.running, + Icon: STATUS_META.running.Icon, + onClick: () => handleStatusNodeClick("running"), + isActive: deviceStatusFilter === "running" + }, + draggable: false, + selectable: false + }, + { + id: "expired", + type: "statusNode", + position: { x: 0, y: 340 }, + data: { + label: STATUS_META.expired.label, + color: STATUS_META.expired.color, + count: statusCounts.expired, + Icon: STATUS_META.expired.Icon, + onClick: () => handleStatusNodeClick("expired"), + isActive: deviceStatusFilter === "expired" + }, + draggable: false, + selectable: false + }, + { + id: "success", + type: "statusNode", + position: { x: 420, y: 0 }, + data: { + label: STATUS_META.success.label, + color: STATUS_META.success.color, + count: statusCounts.success, + Icon: STATUS_META.success.Icon, + onClick: () => handleStatusNodeClick("success"), + isActive: deviceStatusFilter === "success" + }, + draggable: false, + selectable: false + }, + { + id: "failed", + type: "statusNode", + position: { x: 420, y: 340 }, + data: { + label: STATUS_META.failed.label, + color: STATUS_META.failed.color, + count: statusCounts.failed, + Icon: STATUS_META.failed.Icon, + onClick: () => handleStatusNodeClick("failed"), + isActive: deviceStatusFilter === "failed" + }, + draggable: false, + selectable: false + } + ], [statusCounts, handleStatusNodeClick, deviceStatusFilter]); + + const statusEdges = useMemo(() => [ + { + id: "pending-running", + source: "pending", + target: "running", + sourceHandle: "right-top", + targetHandle: "left-top", + type: "smoothstep", + animated: true, + className: "status-flow-edge" + }, + { + id: "pending-expired", + source: "pending", + target: "expired", + sourceHandle: "right-bottom", + targetHandle: "left-bottom", + type: "smoothstep", + animated: true, + className: "status-flow-edge" + }, + { + id: "running-success", + source: "running", + target: "success", + sourceHandle: "right-top", + targetHandle: "left-top", + type: "smoothstep", + animated: true, + className: "status-flow-edge" + }, + { + id: "running-failed", + source: "running", + target: "failed", + sourceHandle: "right-bottom", + targetHandle: "left-bottom", + type: "smoothstep", + animated: true, + className: "status-flow-edge" + } + ], []); + + const JobStatusFlow = () => ( + + + + { + if (node?.id && STATUS_META[node.id]) handleStatusNodeClick(node.id); + }} + selectionOnDrag={false} + proOptions={{ hideAttribution: true }} + style={{ background: "transparent" }} + /> + {deviceStatusFilter ? ( + + + Showing devices with {STATUS_META[deviceStatusFilter]?.label || deviceStatusFilter} results + + + + ) : null} ); + const inferLanguage = useCallback((path = "") => { + const lower = String(path || "").toLowerCase(); + if (lower.endsWith(".ps1")) return "powershell"; + if (lower.endsWith(".bat")) return "batch"; + if (lower.endsWith(".sh")) return "bash"; + if (lower.endsWith(".yml") || lower.endsWith(".yaml")) return "yaml"; + return "powershell"; + }, []); - // --- Devices breakdown --- - const [deviceRows, setDeviceRows] = useState([]); - const deviceSorted = useMemo(() => deviceRows, [deviceRows]); + const highlightCode = useCallback((code, lang) => { + try { + return Prism.highlight(code ?? "", Prism.languages[lang] || Prism.languages.markup, lang); + } catch { + return String(code || ""); + } + }, []); + + const loadActivity = useCallback(async (activityId) => { + const idNum = Number(activityId || 0); + if (!idNum) return null; + if (activityCacheRef.current.has(idNum)) { + return activityCacheRef.current.get(idNum); + } + try { + const resp = await fetch(`/api/device/activity/job/${idNum}`); + if (!resp.ok) throw new Error(`HTTP ${resp.status}`); + const data = await resp.json(); + activityCacheRef.current.set(idNum, data); + return data; + } catch { + return null; + } + }, []); + + const handleViewDeviceOutput = useCallback(async (row, mode = "stdout") => { + if (!row) return; + const label = mode === "stderr" ? "StdErr" : "StdOut"; + const activities = Array.isArray(row.activities) ? row.activities : []; + const relevant = activities.filter((act) => (mode === "stderr" ? act.has_stderr : act.has_stdout)); + setOutputTitle(`${label} - ${row.hostname || ""}`); + setOutputSections([]); + setOutputError(""); + setOutputLoading(true); + setOutputOpen(true); + if (!relevant.length) { + setOutputError(`No ${label} available for this device.`); + setOutputLoading(false); + return; + } + const sections = []; + for (const act of relevant) { + const activityId = Number(act.activity_id || act.id || 0); + if (!activityId) continue; + const data = await loadActivity(activityId); + if (!data) continue; + const content = mode === "stderr" ? (data.stderr || "") : (data.stdout || ""); + const sectionTitle = act.component_name || data.script_name || data.script_path || `Activity ${activityId}`; + sections.push({ + key: `${activityId}-${mode}`, + title: sectionTitle, + path: data.script_path || "", + lang: inferLanguage(data.script_path || ""), + content, + }); + } + if (!sections.length) { + setOutputError(`No ${label} available for this device.`); + } + setOutputSections(sections); + setOutputLoading(false); + }, [inferLanguage, loadActivity]); useEffect(() => { let canceled = false; const hydrate = async () => { if (initialJob && initialJob.id) { setJobName(initialJob.name || ""); + setPageTitleJobName(typeof initialJob.name === "string" ? initialJob.name.trim() : ""); 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))); @@ -663,6 +1313,7 @@ export default function CreateJob({ onCancel, onCreated, initialJob = null }) { setComponentVarErrors({}); } } else if (!initialJob) { + setPageTitleJobName(""); setComponents([]); setComponentVarErrors({}); } @@ -817,9 +1468,16 @@ export default function CreateJob({ onCancel, onCreated, initialJob = null }) { }, [editing]); return ( - + - Create a Scheduled Job + + Create a Scheduled Job + {pageTitleJobName && ( + + {`: "${pageTitleJobName}"`} + + )} + Configure advanced schedulable automation jobs for one or more devices. @@ -864,6 +1522,7 @@ export default function CreateJob({ onCancel, onCreated, initialJob = null }) { placeholder="Example Job Name" value={jobName} onChange={(e) => setJobName(e.target.value)} + onBlur={(e) => setPageTitleJobName(e.target.value.trim())} InputLabelProps={{ shrink: true }} error={jobName.trim().length === 0} helperText={jobName.trim().length === 0 ? "Job name is required" : ""} @@ -1027,7 +1686,7 @@ export default function CreateJob({ onCancel, onCreated, initialJob = null }) { Showing the last 30 days of runs. - + @@ -1036,12 +1695,26 @@ export default function CreateJob({ onCancel, onCreated, initialJob = null }) { - Hostname - Status - Site - Ran On - Job Status - StdOut / StdErr + {DEVICE_COLUMNS.map((col) => ( + + + handleDeviceSort(col.key)} + > + {col.label} + + openFilterMenu(event, col.key)} + sx={{ color: isColumnFiltered(col.key) ? "#58a6ff" : "#666" }} + > + + + + + ))} @@ -1057,8 +1730,24 @@ export default function CreateJob({ onCancel, onCreated, initialJob = null }) { {resultChip(d.job_status)} - {d.has_stdout ? : null} - {d.has_stderr ? : null} + {d.has_stdout ? ( + + ) : null} + {d.has_stderr ? ( + + ) : null} @@ -1070,6 +1759,25 @@ export default function CreateJob({ onCancel, onCreated, initialJob = null }) { )}
+ + + {renderFilterControl()} + + + + + +
@@ -1083,6 +1791,48 @@ export default function CreateJob({ onCancel, onCreated, initialJob = null }) { )} + setOutputOpen(false)} fullWidth maxWidth="md" + PaperProps={{ sx: { bgcolor: "#121212", color: "#fff" } }} + > + {outputTitle} + + {outputLoading ? ( + Loading output… + ) : null} + {!outputLoading && outputError ? ( + {outputError} + ) : null} + {!outputLoading && !outputError ? ( + outputSections.map((section) => ( + + {section.title} + {section.path ? ( + {section.path} + ) : null} + + {}} + highlight={(code) => highlightCode(code, section.lang)} + padding={12} + style={{ + fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace', + fontSize: 12, + color: "#e6edf3", + minHeight: 160 + }} + textareaProps={{ readOnly: true }} + /> + + + )) + ) : null} + + + + + + {/* Bottom actions removed per design; actions live next to tabs. */} {/* Add Component Dialog */} diff --git a/Data/Server/WebUI/src/Scheduling/Scheduled_Jobs_List.jsx b/Data/Server/WebUI/src/Scheduling/Scheduled_Jobs_List.jsx index 0439371..b0be194 100644 --- a/Data/Server/WebUI/src/Scheduling/Scheduled_Jobs_List.jsx +++ b/Data/Server/WebUI/src/Scheduling/Scheduled_Jobs_List.jsx @@ -254,7 +254,7 @@ export default function ScheduledJobsList({ onCreateJob, onEditJob, refreshToken
{[ ["name", "Name"], - ["scriptWorkflow", "Script / Workflow"], + ["scriptWorkflow", "Assembly(s)"], ["target", "Target"], ["occurrence", "Recurrence"], ["lastRun", "Last Run"], diff --git a/Data/Server/job_scheduler.py b/Data/Server/job_scheduler.py index e02e093..1487223 100644 --- a/Data/Server/job_scheduler.py +++ b/Data/Server/job_scheduler.py @@ -475,14 +475,14 @@ class JobScheduler: os.path.join(os.path.dirname(__file__), "..", "..", "Assemblies", "Ansible_Playbooks") ) - def _dispatch_ansible(self, hostname: str, rel_path: str, scheduled_job_id: int, scheduled_run_id: int) -> None: + def _dispatch_ansible(self, hostname: str, rel_path: str, scheduled_job_id: int, scheduled_run_id: int) -> Optional[Dict[str, Any]]: try: import os, json, uuid ans_root = self._ansible_root() rel_norm = (rel_path or "").replace("\\", "/").lstrip("/") abs_path = os.path.abspath(os.path.join(ans_root, rel_norm)) if (not abs_path.startswith(ans_root)) or (not os.path.isfile(abs_path)): - return + return None doc = self._load_assembly_document(abs_path, "ansible") content = doc.get("script") or "" encoded_content = _encode_script_content(content) @@ -503,7 +503,7 @@ class JobScheduler: ( str(hostname), rel_norm, - os.path.basename(abs_path), + doc.get("name") or os.path.basename(abs_path), "ansible", now, "Running", @@ -519,7 +519,7 @@ class JobScheduler: payload = { "run_id": uuid.uuid4().hex, "target_hostname": str(hostname), - "playbook_name": os.path.basename(abs_path), + "playbook_name": doc.get("name") or os.path.basename(abs_path), "playbook_content": encoded_content, "playbook_encoding": "base64", "activity_job_id": act_id, @@ -533,10 +533,19 @@ class JobScheduler: self.socketio.emit("ansible_playbook_run", payload) except Exception: pass + if act_id: + return { + "activity_id": int(act_id), + "component_name": doc.get("name") or os.path.basename(abs_path), + "component_path": rel_norm, + "script_type": "ansible", + "component_kind": "ansible", + } + return None except Exception: pass - def _dispatch_script(self, hostname: str, component: Dict[str, Any], run_mode: str) -> None: + def _dispatch_script(self, hostname: str, component: Dict[str, Any], run_mode: str) -> Optional[Dict[str, Any]]: """Emit a quick_job_run event to agents for the given script/host. Mirrors /api/scripts/quick_run behavior for scheduled jobs. """ @@ -553,12 +562,12 @@ class JobScheduler: path_norm = f"Scripts/{path_norm}" abs_path = os.path.abspath(os.path.join(scripts_root, path_norm)) if (not abs_path.startswith(scripts_root)) or (not self._is_valid_scripts_relpath(path_norm)) or (not os.path.isfile(abs_path)): - return + return None doc = self._load_assembly_document(abs_path, "powershell") stype = (doc.get("type") or "powershell").lower() # For now, only PowerShell is supported by agents for scheduled jobs if stype != "powershell": - return + return None content = doc.get("script") or "" doc_variables = doc.get("variables") if isinstance(doc.get("variables"), list) else [] @@ -603,7 +612,7 @@ class JobScheduler: ( str(hostname), path_norm, - os.path.basename(abs_path), + doc.get("name") or os.path.basename(abs_path), stype, now, "Running", @@ -620,7 +629,7 @@ class JobScheduler: "job_id": act_id, "target_hostname": str(hostname), "script_type": stype, - "script_name": os.path.basename(abs_path), + "script_name": doc.get("name") or os.path.basename(abs_path), "script_path": path_norm, "script_content": encoded_content, "script_encoding": "base64", @@ -636,9 +645,19 @@ class JobScheduler: self.socketio.emit("quick_job_run", payload) except Exception: pass + if act_id: + return { + "activity_id": int(act_id), + "component_name": doc.get("name") or os.path.basename(abs_path), + "component_path": path_norm, + "script_type": stype, + "component_kind": "script", + } + return None except Exception: # Keep scheduler resilient pass + return None # ---------- DB helpers ---------- def _conn(self): @@ -677,6 +696,27 @@ class JobScheduler: cur.execute("CREATE INDEX IF NOT EXISTS idx_runs_job_sched_target ON scheduled_job_runs(job_id, scheduled_ts, target_hostname)") except Exception: pass + try: + cur.execute( + """ + CREATE TABLE IF NOT EXISTS scheduled_job_run_activity ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + run_id INTEGER NOT NULL, + activity_id INTEGER NOT NULL, + component_kind TEXT, + script_type TEXT, + component_path TEXT, + component_name TEXT, + created_at INTEGER, + FOREIGN KEY(run_id) REFERENCES scheduled_job_runs(id) ON DELETE CASCADE, + FOREIGN KEY(activity_id) REFERENCES activity_history(id) ON DELETE CASCADE + ) + """ + ) + cur.execute("CREATE INDEX IF NOT EXISTS idx_run_activity_run ON scheduled_job_run_activity(run_id)") + cur.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_run_activity_activity ON scheduled_job_run_activity(activity_id)") + except Exception: + pass conn.commit() conn.close() @@ -970,18 +1010,55 @@ class JobScheduler: ) run_row_id = c2.lastrowid or 0 conn2.commit() + activity_links: List[Dict[str, Any]] = [] # Dispatch all script components for this job to the target host for comp in script_components: try: - self._dispatch_script(host, comp, run_mode) + link = self._dispatch_script(host, comp, run_mode) + if link and link.get("activity_id"): + activity_links.append({ + "run_id": run_row_id, + "activity_id": int(link["activity_id"]), + "component_kind": link.get("component_kind") or "script", + "script_type": link.get("script_type") or "powershell", + "component_path": link.get("component_path") or "", + "component_name": link.get("component_name") or "", + }) except Exception: continue # Dispatch ansible playbooks for this job to the target host for ap in ansible_paths: try: - self._dispatch_ansible(host, ap, job_id, run_row_id) + link = self._dispatch_ansible(host, ap, job_id, run_row_id) + if link and link.get("activity_id"): + activity_links.append({ + "run_id": run_row_id, + "activity_id": int(link["activity_id"]), + "component_kind": link.get("component_kind") or "ansible", + "script_type": link.get("script_type") or "ansible", + "component_path": link.get("component_path") or "", + "component_name": link.get("component_name") or "", + }) except Exception: continue + if activity_links: + try: + for link in activity_links: + c2.execute( + "INSERT OR IGNORE INTO scheduled_job_run_activity(run_id, activity_id, component_kind, script_type, component_path, component_name, created_at) VALUES (?,?,?,?,?,?,?)", + ( + int(link["run_id"]), + int(link["activity_id"]), + link.get("component_kind") or "", + link.get("script_type") or "", + link.get("component_path") or "", + link.get("component_name") or "", + ts_now, + ), + ) + conn2.commit() + except Exception: + pass except Exception: pass finally: @@ -1389,21 +1466,60 @@ class JobScheduler: # Status per target for occurrence run_by_host: Dict[str, Dict[str, Any]] = {} + run_ids: List[int] = [] if occ is not None: try: cur.execute( - "SELECT target_hostname, status, started_ts, finished_ts FROM scheduled_job_runs WHERE job_id=? AND scheduled_ts=? ORDER BY id DESC", + "SELECT id, target_hostname, status, started_ts, finished_ts FROM scheduled_job_runs WHERE job_id=? AND scheduled_ts=? ORDER BY id DESC", (job_id, occ) ) rows = cur.fetchall() - for h, st, st_ts, fin_ts in rows: + for rid, h, st, st_ts, fin_ts in rows: h = str(h) if h not in run_by_host: run_by_host[h] = { "status": st or "", "started_ts": st_ts, "finished_ts": fin_ts, + "run_id": int(rid), } + run_ids.append(int(rid)) + except Exception: + pass + + activities_by_run: Dict[int, List[Dict[str, Any]]] = {} + if run_ids: + try: + placeholders = ",".join(["?"] * len(run_ids)) + cur.execute( + f""" + SELECT + s.run_id, + s.activity_id, + s.component_kind, + s.script_type, + s.component_path, + s.component_name, + COALESCE(LENGTH(h.stdout), 0), + COALESCE(LENGTH(h.stderr), 0) + FROM scheduled_job_run_activity s + LEFT JOIN activity_history h ON h.id = s.activity_id + WHERE s.run_id IN ({placeholders}) + """, + run_ids, + ) + for rid, act_id, kind, stype, path, name, so_len, se_len in cur.fetchall(): + rid = int(rid) + entry = { + "activity_id": int(act_id), + "component_kind": kind or "", + "script_type": stype or "", + "component_path": path or "", + "component_name": name or "", + "has_stdout": bool(so_len), + "has_stderr": bool(se_len), + } + activities_by_run.setdefault(rid, []).append(entry) except Exception: pass @@ -1422,14 +1538,18 @@ class JobScheduler: rec = run_by_host.get(str(host), {}) job_status = rec.get("status") or "Pending" ran_on = rec.get("started_ts") or rec.get("finished_ts") + activities = activities_by_run.get(rec.get("run_id", 0) or 0, []) + has_stdout = any(a.get("has_stdout") for a in activities) + has_stderr = any(a.get("has_stderr") for a in activities) out.append({ "hostname": str(host), "online": str(host) in online, "site": site_by_host.get(str(host), ""), "ran_on": ran_on, "job_status": job_status, - "has_stdout": False, - "has_stderr": False, + "has_stdout": has_stdout, + "has_stderr": has_stderr, + "activities": activities, }) return json.dumps({"occurrence": occ, "devices": out}), 200, {"Content-Type": "application/json"} diff --git a/Data/Server/server.py b/Data/Server/server.py index 5562395..acd4c40 100644 --- a/Data/Server/server.py +++ b/Data/Server/server.py @@ -4958,6 +4958,7 @@ def scripts_quick_run(): timeout_seconds = max(0, int(doc.get("timeout_seconds") or 0)) except Exception: timeout_seconds = 0 + friendly_name = (doc.get("name") or "").strip() or _safe_filename(rel_path) now = int(time.time()) results = [] @@ -4974,7 +4975,7 @@ def scripts_quick_run(): ( host, rel_path.replace(os.sep, "/"), - _safe_filename(rel_path), + friendly_name, script_type, now, "Running", @@ -4992,7 +4993,7 @@ def scripts_quick_run(): "job_id": job_id, "target_hostname": host, "script_type": script_type, - "script_name": _safe_filename(rel_path), + "script_name": friendly_name, "script_path": rel_path.replace(os.sep, "/"), "script_content": encoded_content, "script_encoding": "base64", @@ -5043,6 +5044,7 @@ def ansible_quick_run(): encoded_content = _encode_script_content(content) variables = doc.get('variables') if isinstance(doc.get('variables'), list) else [] files = doc.get('files') if isinstance(doc.get('files'), list) else [] + friendly_name = (doc.get("name") or "").strip() or os.path.basename(abs_path) results = [] for host in hostnames: @@ -5060,7 +5062,7 @@ def ansible_quick_run(): ( str(host), rel_path.replace(os.sep, "/"), - os.path.basename(abs_path), + friendly_name, "ansible", now_ts, "Running", @@ -5082,7 +5084,7 @@ def ansible_quick_run(): payload = { "run_id": run_id, "target_hostname": str(host), - "playbook_name": os.path.basename(abs_path), + "playbook_name": friendly_name, "playbook_content": encoded_content, "playbook_encoding": "base64", "connection": "winrm", @@ -5192,6 +5194,28 @@ def handle_quick_job_result(data): (status, stdout, stderr, job_id), ) conn.commit() + try: + cur.execute( + "SELECT run_id FROM scheduled_job_run_activity WHERE activity_id=?", + (job_id,), + ) + link = cur.fetchone() + if link: + run_id = int(link[0]) + ts_now = _now_ts() + if status.lower() == "running": + cur.execute( + "UPDATE scheduled_job_runs SET status='Running', updated_at=? WHERE id=?", + (ts_now, run_id), + ) + else: + cur.execute( + "UPDATE scheduled_job_runs SET status=?, finished_ts=COALESCE(finished_ts, ?), updated_at=? WHERE id=?", + (status, ts_now, ts_now, run_id), + ) + conn.commit() + except Exception: + pass try: cur.execute( "SELECT id, hostname, status FROM activity_history WHERE id=?",