From deeacb148ed6d060562d0741d9c00124282e345a Mon Sep 17 00:00:00 2001 From: Nicole Rappe Date: Mon, 3 Nov 2025 23:45:54 -0700 Subject: [PATCH] Assembly Execution (SYSTEM & CURRENTUSER) Resolved --- Data/Engine/Assemblies/community.db-shm | Bin 0 -> 32768 bytes Data/Engine/Assemblies/community.db-wal | 0 Data/Engine/Assemblies/official.db-shm | Bin 0 -> 32768 bytes Data/Engine/Assemblies/official.db-wal | 0 Data/Engine/Assemblies/user_created.db-shm | Bin 0 -> 32768 bytes Data/Engine/Assemblies/user_created.db-wal | 0 Data/Engine/services/API/__init__.py | 8 +- .../src/Assemblies/Assembly_Editor.jsx | 79 +-- .../src/Assemblies/assemblyUtils.js | 541 ++++++++++++++++++ .../src/Scheduling/Create_Job.jsx | 406 +++++++------ .../src/Scheduling/Quick_Job.jsx | 365 ++++++------ .../src/Scheduling/Scheduled_Jobs_List.jsx | 105 +++- 12 files changed, 1047 insertions(+), 457 deletions(-) create mode 100644 Data/Engine/Assemblies/community.db-shm create mode 100644 Data/Engine/Assemblies/community.db-wal create mode 100644 Data/Engine/Assemblies/official.db-shm create mode 100644 Data/Engine/Assemblies/official.db-wal create mode 100644 Data/Engine/Assemblies/user_created.db-shm create mode 100644 Data/Engine/Assemblies/user_created.db-wal create mode 100644 Data/Engine/web-interface/src/Assemblies/assemblyUtils.js diff --git a/Data/Engine/Assemblies/community.db-shm b/Data/Engine/Assemblies/community.db-shm new file mode 100644 index 0000000000000000000000000000000000000000..fe9ac2845eca6fe6da8a63cd096d9cf9e24ece10 GIT binary patch literal 32768 zcmeIuAr62r3 Non scheduled_jobs_management.register_management(app, adapters) +def _register_assemblies(app: Flask, adapters: EngineServiceAdapters) -> None: + register_assemblies(app, adapters) + register_execution(app, adapters) + + _GROUP_REGISTRARS: Mapping[str, Callable[[Flask, EngineServiceAdapters], None]] = { "auth": register_auth, "tokens": _register_tokens, "enrollment": _register_enrollment, "devices": _register_devices, - "assemblies": register_assemblies, + "assemblies": _register_assemblies, "scheduled_jobs": _register_scheduled_jobs, } diff --git a/Data/Engine/web-interface/src/Assemblies/Assembly_Editor.jsx b/Data/Engine/web-interface/src/Assemblies/Assembly_Editor.jsx index c64f92d7..0f2b40e5 100644 --- a/Data/Engine/web-interface/src/Assemblies/Assembly_Editor.jsx +++ b/Data/Engine/web-interface/src/Assemblies/Assembly_Editor.jsx @@ -27,6 +27,11 @@ import "prismjs/themes/prism-okaidia.css"; import Editor from "react-simple-code-editor"; import { ConfirmDeleteDialog } from "../Dialogs"; import { DomainBadge, DirtyStatePill, DOMAIN_OPTIONS } from "./Assembly_Badges"; +import { + decodeBase64String, + normalizeVariablesFromServer, + normalizeFilesFromServer +} from "./assemblyUtils"; const TYPE_OPTIONS_ALL = [ { key: "ansible", label: "Ansible Playbook", prism: "yaml" }, @@ -201,70 +206,6 @@ function defaultAssembly(defaultType = "powershell") { }; } -function normalizeVariablesFromServer(vars = []) { - return (Array.isArray(vars) ? vars : []).map((v, idx) => ({ - id: `${Date.now()}_${idx}_${Math.random().toString(36).slice(2, 8)}`, - name: v?.name || v?.key || "", - label: v?.label || "", - type: v?.type || "string", - defaultValue: v?.default ?? v?.default_value ?? "", - required: Boolean(v?.required), - description: v?.description || "" - })); -} - -function decodeBase64String(data = "") { - if (typeof data !== "string") { - return { success: false, value: "" }; - } - - const trimmed = data.trim(); - if (!trimmed) { - return { success: true, value: "" }; - } - - const sanitized = trimmed.replace(/\s+/g, ""); - - try { - if (typeof window !== "undefined" && typeof window.atob === "function") { - const binary = window.atob(sanitized); - if (typeof TextDecoder !== "undefined") { - try { - const decoder = new TextDecoder("utf-8", { fatal: false }); - return { - success: true, - value: decoder.decode(Uint8Array.from(binary, (c) => c.charCodeAt(0))) - }; - } catch (err) { - // fall through to manual reconstruction - } - } - - let decoded = ""; - for (let i = 0; i < binary.length; i += 1) { - decoded += String.fromCharCode(binary.charCodeAt(i)); - } - try { - return { success: true, value: decodeURIComponent(escape(decoded)) }; - } catch (err) { - return { success: true, value: decoded }; - } - } - } catch (err) { - // fall through to Buffer fallback - } - - try { - if (typeof Buffer !== "undefined") { - return { success: true, value: Buffer.from(sanitized, "base64").toString("utf-8") }; - } - } catch (err) { - // ignore - } - - return { success: false, value: "" }; -} - function encodeBase64String(text = "") { if (typeof text !== "string") { text = text == null ? "" : String(text); @@ -291,16 +232,6 @@ function encodeBase64String(text = "") { return ""; } -function normalizeFilesFromServer(files = []) { - return (Array.isArray(files) ? files : []).map((f, idx) => ({ - id: `${Date.now()}_${idx}_${Math.random().toString(36).slice(2, 8)}`, - fileName: f?.file_name || f?.name || "file.bin", - size: f?.size || 0, - mimeType: f?.mime_type || f?.mimeType || "", - data: f?.data || "" - })); -} - function fromServerDocument(doc = {}, defaultType = "powershell") { const assembly = defaultAssembly(defaultType); if (doc && typeof doc === "object") { diff --git a/Data/Engine/web-interface/src/Assemblies/assemblyUtils.js b/Data/Engine/web-interface/src/Assemblies/assemblyUtils.js new file mode 100644 index 00000000..318794f2 --- /dev/null +++ b/Data/Engine/web-interface/src/Assemblies/assemblyUtils.js @@ -0,0 +1,541 @@ +/** + * Shared assembly utilities for normalizing API payloads, decoding legacy script bodies, + * and building frontend-friendly indexes from the cache-backed assemblies REST API. + */ + +import { resolveDomainMeta } from "./Assembly_Badges"; + +const KIND_PREFIX = { + ansible: "Ansible_Playbooks", + workflow: "Workflows", + script: "Scripts" +}; + +const TRIM_PREFIX_MATCHERS = [ + /^scripts\//, + /^ansible_playbooks\//, + /^workflows\// +]; + +const FALLBACK_ASSEMBLY_NAME = "Assembly"; + +const toLowerSafe = (value) => (typeof value === "string" ? value.toLowerCase() : ""); + +const sanitizeNameForPath = (value, fallback = FALLBACK_ASSEMBLY_NAME) => { + if (typeof value !== "string" || !value.trim()) return fallback; + return value.trim().replace(/[^a-zA-Z0-9._-]+/g, "_"); +}; + +export function decodeBase64String(data = "") { + if (typeof data !== "string") { + return { success: false, value: "" }; + } + + const trimmed = data.trim(); + if (!trimmed) { + return { success: true, value: "" }; + } + + const sanitized = trimmed.replace(/\s+/g, ""); + + try { + if (typeof window !== "undefined" && typeof window.atob === "function") { + const binary = window.atob(sanitized); + if (typeof TextDecoder !== "undefined") { + try { + const decoder = new TextDecoder("utf-8", { fatal: false }); + return { + success: true, + value: decoder.decode(Uint8Array.from(binary, (c) => c.charCodeAt(0))) + }; + } catch { + // fall through to manual reconstruction + } + } + + let decoded = ""; + for (let i = 0; i < binary.length; i += 1) { + decoded += String.fromCharCode(binary.charCodeAt(i)); + } + try { + return { success: true, value: decodeURIComponent(escape(decoded)) }; + } catch { + return { success: true, value: decoded }; + } + } + } catch { + // fall through to Buffer fallback + } + + try { + if (typeof Buffer !== "undefined") { + return { success: true, value: Buffer.from(sanitized, "base64").toString("utf-8") }; + } + } catch { + // ignore Buffer decode errors + } + + return { success: false, value: "" }; +} + +export function normalizeVariablesFromServer(vars = []) { + return (Array.isArray(vars) ? vars : []).map((v, idx) => ({ + id: `${Date.now()}_${idx}_${Math.random().toString(36).slice(2, 8)}`, + name: v?.name || v?.key || "", + label: v?.label || "", + type: v?.type || "string", + defaultValue: v?.default ?? v?.default_value ?? "", + required: Boolean(v?.required), + description: v?.description || "" + })); +} + +export function normalizeFilesFromServer(files = []) { + return (Array.isArray(files) ? files : []).map((f, idx) => ({ + id: `${Date.now()}_${idx}_${Math.random().toString(36).slice(2, 8)}`, + fileName: f?.file_name || f?.name || "file.bin", + size: f?.size || 0, + mimeType: f?.mime_type || f?.mimeType || "", + data: f?.data || "" + })); +} + +export function normalizeAssemblyVariables(vars = []) { + return normalizeVariablesFromServer(vars).map((v) => ({ + name: v.name || "", + label: v.label || v.name || "", + type: (v.type || "string").toLowerCase(), + required: Boolean(v.required), + description: v.description || "", + default: v.defaultValue ?? "" + })); +} + +export function normalizeAssemblyFiles(files = []) { + return normalizeFilesFromServer(files); +} + +export function normalizeAssemblyPath(kind = "script", rawPath = "", fallbackName = "") { + const kindKey = toLowerSafe(kind); + const prefix = KIND_PREFIX[kindKey] || KIND_PREFIX.script; + + let candidate = typeof rawPath === "string" ? rawPath : ""; + candidate = candidate.replace(/\\/g, "/").replace(/^\/+/, "").trim(); + + const lowered = candidate.toLowerCase(); + const prefixLower = prefix.toLowerCase(); + if (lowered.startsWith(`${prefixLower}/`)) { + candidate = candidate.slice(prefix.length + 1); + } + + if (!candidate) { + const safeName = sanitizeNameForPath(fallbackName, FALLBACK_ASSEMBLY_NAME); + candidate = safeName; + } + + const normalized = `${prefix}/${candidate}`.replace(/\/+/g, "/"); + return normalized; +} + +export function canonicalPathKey(domain, path) { + const domainPart = (domain || "user").toLowerCase(); + const pathPart = (path || "").replace(/\\/g, "/").replace(/^\/+/, "").toLowerCase(); + return `${domainPart}:${pathPart}`; +} + +export function normalizeAssemblyRecord(item, queueEntry = null) { + if (!item || typeof item !== "object") { + return null; + } + + const assemblyGuid = (item.assembly_guid || item.assembly_id || "").toString().trim(); + if (!assemblyGuid) { + return null; + } + + const metadata = item.metadata && typeof item.metadata === "object" ? { ...item.metadata } : {}; + const domain = toLowerSafe(item.source || item.domain || metadata.domain || "user") || "user"; + const domainMeta = resolveDomainMeta(domain); + + let kind = toLowerSafe(item.assembly_kind || metadata.assembly_kind); + const typeHint = toLowerSafe(item.assembly_type || metadata.assembly_type); + if (!kind) { + if (typeHint === "ansible") kind = "ansible"; + else if (typeHint === "workflow") kind = "workflow"; + else kind = "script"; + } + + let assemblyType = typeHint || (kind === "ansible" ? "ansible" : kind === "workflow" ? "workflow" : "powershell"); + if (!assemblyType) { + assemblyType = kind === "ansible" ? "ansible" : kind === "workflow" ? "workflow" : "powershell"; + } + + const displayName = + item.display_name || + metadata.display_name || + item.summary || + metadata.name || + sanitizeNameForPath(assemblyGuid); + const summary = item.summary || metadata.summary || ""; + + const rawPath = + metadata.source_path || + metadata.rel_path || + metadata.legacy_path || + metadata.path || + metadata.relative_path || + ""; + const normalizedPath = normalizeAssemblyPath(kind, rawPath, displayName); + const pathLower = normalizedPath.toLowerCase(); + let pathLowerNoPrefix = pathLower; + TRIM_PREFIX_MATCHERS.forEach((matcher) => { + if (pathLowerNoPrefix.match(matcher)) { + pathLowerNoPrefix = pathLowerNoPrefix.replace(matcher, ""); + } + }); + + const queueDirtySince = queueEntry?.dirty_since || item.dirty_since || null; + const queueLastPersisted = queueEntry?.last_persisted || item.last_persisted || null; + const queueIsDirty = + typeof queueEntry?.is_dirty === "string" + ? queueEntry.is_dirty.toLowerCase() === "true" + : queueEntry?.is_dirty ?? Boolean(item.is_dirty); + + const segments = normalizedPath.split("/").filter(Boolean); + + return { + assemblyGuid, + assemblyGuidLower: assemblyGuid.toLowerCase(), + displayName, + summary, + kind, + type: assemblyType, + domain, + domainLabel: domainMeta.label, + metadata, + tags: item.tags && typeof item.tags === "object" ? { ...item.tags } : {}, + path: normalizedPath, + pathLower, + pathLowerNoPrefix, + segments, + isDirty: queueIsDirty, + dirtySince: queueDirtySince, + lastPersisted: queueLastPersisted, + payloadGuid: item.payload_guid, + createdAt: item.created_at, + updatedAt: item.updated_at, + queueEntry, + raw: item + }; +} + +export function buildAssemblyIndex(items = [], queue = []) { + const queueMap = new Map(); + (Array.isArray(queue) ? queue : []).forEach((entry) => { + if (!entry || typeof entry !== "object") return; + const guid = (entry.assembly_guid || entry.guid || "").toString().trim().toLowerCase(); + if (guid) { + queueMap.set(guid, entry); + } + }); + + const records = []; + const byGuid = new Map(); + const byPath = new Map(); + + (Array.isArray(items) ? items : []).forEach((item) => { + const guidLower = (item?.assembly_guid || item?.assembly_id || "").toString().trim().toLowerCase(); + const queueEntry = guidLower ? queueMap.get(guidLower) || null : null; + const record = normalizeAssemblyRecord(item, queueEntry); + if (!record) return; + records.push(record); + byGuid.set(record.assemblyGuidLower, record); + + const registerPath = (path) => { + if (!path) return; + const key = path.toLowerCase(); + if (!byPath.has(key)) { + byPath.set(key, record); + } + }; + + registerPath(record.pathLower); + registerPath(record.pathLowerNoPrefix); + }); + + records.sort((a, b) => { + const left = a.pathLower; + const right = b.pathLower; + if (left < right) return -1; + if (left > right) return 1; + return a.displayName.localeCompare(b.displayName, undefined, { sensitivity: "base" }); + }); + + const grouped = { + scripts: records.filter((r) => r.kind === "script" && r.type !== "ansible"), + ansible: records.filter((r) => r.kind === "ansible" || r.type === "ansible"), + workflows: records.filter((r) => r.kind === "workflow") + }; + + return { records, byGuid, byPath, grouped }; +} + +function sortChildren(node) { + if (!node || !Array.isArray(node.children) || !node.children.length) return; + node.children.sort((a, b) => { + if (a.isFolder !== b.isFolder) { + return a.isFolder ? -1 : 1; + } + return (a.label || "").localeCompare(b.label || "", undefined, { sensitivity: "base" }); + }); + node.children.forEach(sortChildren); +} + +export function buildAssemblyTree(records = [], { rootLabel = "Assemblies", includeDomain = true } = {}) { + const map = {}; + const root = { + id: "root", + label: rootLabel, + isFolder: true, + children: [] + }; + map[root.id] = root; + + const domainNodes = new Map(); + const sortedRecords = Array.isArray(records) ? [...records] : []; + sortedRecords.sort((a, b) => a.pathLower.localeCompare(b.pathLower)); + + sortedRecords.forEach((record) => { + let parent = root; + if (includeDomain) { + let domainNode = domainNodes.get(record.domain); + if (!domainNode) { + domainNode = { + id: `domain:${record.domain}`, + label: record.domainLabel || record.domain, + isFolder: true, + domain: record.domain, + children: [] + }; + domainNodes.set(record.domain, domainNode); + root.children.push(domainNode); + map[domainNode.id] = domainNode; + } + parent = domainNode; + } + + const segments = record.segments.length ? record.segments : [record.displayName || record.assemblyGuid]; + let aggregate = ""; + segments.forEach((segment, index) => { + const safeSegment = segment || record.displayName || FALLBACK_ASSEMBLY_NAME; + aggregate = aggregate ? `${aggregate}/${safeSegment}` : safeSegment; + const nodeKey = canonicalPathKey(record.domain, aggregate); + let node = map[nodeKey]; + const isLeaf = index === segments.length - 1; + if (!node) { + node = { + id: nodeKey, + label: isLeaf ? record.displayName || safeSegment : safeSegment, + isFolder: !isLeaf, + domain: record.domain, + children: [] + }; + parent.children.push(node); + map[nodeKey] = node; + } + if (isLeaf) { + node.assembly = record; + node.assemblyGuid = record.assemblyGuid; + node.path = record.path; + node.label = record.displayName || safeSegment; + map[`assembly:${record.assemblyGuidLower}`] = node; + } else { + parent = node; + } + }); + }); + + sortChildren(root); + return { root: root.children, map }; +} + +export function parseAssemblyExport(exportDoc) { + if (!exportDoc || typeof exportDoc !== "object") { + return { + metadata: {}, + payload: {}, + kind: "script", + type: "powershell", + script: "", + variables: [], + variablesDetailed: [], + files: [], + rawVariables: [], + rawFiles: [], + timeoutSeconds: 0, + sites: { mode: "all", values: [] } + }; + } + + const metadata = exportDoc.metadata && typeof exportDoc.metadata === "object" ? { ...exportDoc.metadata } : {}; + let payload = exportDoc.payload; + if (typeof payload === "string") { + try { + payload = JSON.parse(payload); + } catch { + payload = {}; + } + } + if (!payload || typeof payload !== "object") { + payload = {}; + } + + const kindHint = toLowerSafe(exportDoc.assembly_kind || metadata.assembly_kind || payload.assembly_kind); + const typeHint = toLowerSafe(exportDoc.assembly_type || metadata.assembly_type || payload.type); + let kind = "script"; + if (kindHint === "ansible" || typeHint === "ansible") { + kind = "ansible"; + } else if (kindHint === "workflow" || (Array.isArray(payload.nodes) && Array.isArray(payload.edges))) { + kind = "workflow"; + } + let type = typeHint; + if (!type) { + if (kind === "ansible") type = "ansible"; + else if (kind === "workflow") type = "workflow"; + else type = "powershell"; + } + + const scriptLines = Array.isArray(payload.script_lines) ? payload.script_lines : null; + let scriptSource = ""; + if (typeof payload.script === "string") scriptSource = payload.script; + else if (typeof payload.content === "string") scriptSource = payload.content; + else if (scriptLines) scriptSource = scriptLines.map((line) => (line == null ? "" : String(line))).join("\n"); + + const encodingHint = toLowerSafe( + payload.script_encoding || + payload.scriptEncoding || + metadata.script_encoding || + metadata.scriptEncoding + ); + let script = ""; + if (typeof scriptSource === "string") { + if (encodingHint && ["base64", "b64", "base-64"].includes(encodingHint)) { + const decoded = decodeBase64String(scriptSource); + script = decoded.success ? decoded.value : scriptSource; + } else { + const decoded = decodeBase64String(scriptSource); + script = decoded.success ? decoded.value : scriptSource.replace(/\r\n/g, "\n"); + } + } + + const variablesRaw = Array.isArray(payload.variables) + ? payload.variables + : Array.isArray(metadata.variables) + ? metadata.variables + : []; + const filesRaw = Array.isArray(payload.files) + ? payload.files + : Array.isArray(metadata.files) + ? metadata.files + : []; + + const variablesDetailed = normalizeVariablesFromServer(variablesRaw); + const variables = normalizeAssemblyVariables(variablesRaw); + const files = normalizeAssemblyFiles(filesRaw); + + const timeoutCandidate = + payload.timeout_seconds ?? + payload.timeout ?? + metadata.timeout_seconds ?? + metadata.timeoutSeconds ?? + metadata.timeout ?? + null; + const timeoutSeconds = Number.isFinite(Number(timeoutCandidate)) ? Number(timeoutCandidate) : 0; + + const sites = + (payload.sites && typeof payload.sites === "object" ? payload.sites : null) || + (metadata.sites && typeof metadata.sites === "object" ? metadata.sites : null) || + { mode: "all", values: [] }; + + return { + metadata, + payload, + kind, + type, + script, + variables, + variablesDetailed, + files, + rawVariables: variablesRaw, + rawFiles: filesRaw, + timeoutSeconds, + sites + }; +} + +export function resolveAssemblyForComponent(index, component = {}, kindHint = null) { + if (!index || typeof index !== "object") return null; + const { byGuid, byPath } = index; + const guidRaw = + component?.assembly_guid || + component?.assemblyGuid || + component?.assembly_id || + component?.assemblyID || + component?.guid; + if (guidRaw) { + const guid = guidRaw.toString().trim().toLowerCase(); + if (guid && byGuid?.has(guid)) { + return byGuid.get(guid); + } + } + + const determineKind = () => { + const componentType = toLowerSafe(component?.type || component?.component_type || component?.mode); + if (componentType === "ansible" || componentType === "playbook") return "ansible"; + if (componentType === "workflow") return "workflow"; + if (kindHint) return kindHint; + return "script"; + }; + + const kind = determineKind(); + const candidatePaths = []; + const pushCandidate = (value) => { + if (!value || typeof value !== "string") return; + const normalized = normalizeAssemblyPath(kind, value); + if (!candidatePaths.includes(normalized)) { + candidatePaths.push(normalized); + } + const trimmed = normalized.toLowerCase().replace(TRIM_PREFIX_MATCHERS[0], ""); + if (trimmed && !candidatePaths.includes(trimmed)) { + candidatePaths.push(trimmed); + } + }; + + const rawPath = + component?.path || + component?.script_path || + component?.scriptPath || + component?.playbook_path || + component?.playbookPath || + component?.workflow_path || + component?.workflowPath || + ""; + if (rawPath) { + pushCandidate(rawPath); + pushCandidate(rawPath.replace(/\\/g, "/")); + } + + const nameHint = component?.name || component?.tab_name || component?.display_name; + if (nameHint) { + pushCandidate(nameHint); + } + + for (const candidate of candidatePaths) { + const key = candidate.toLowerCase(); + if (byPath?.has(key)) { + return byPath.get(key); + } + } + + return null; +} diff --git a/Data/Engine/web-interface/src/Scheduling/Create_Job.jsx b/Data/Engine/web-interface/src/Scheduling/Create_Job.jsx index 89b09962..c791f39a 100644 --- a/Data/Engine/web-interface/src/Scheduling/Create_Job.jsx +++ b/Data/Engine/web-interface/src/Scheduling/Create_Job.jsx @@ -54,6 +54,14 @@ import "prismjs/themes/prism-okaidia.css"; import Editor from "react-simple-code-editor"; import ReactFlow, { Handle, Position } from "reactflow"; import "reactflow/dist/style.css"; +import { DomainBadge } from "../Assemblies/Assembly_Badges"; +import { + buildAssemblyIndex, + buildAssemblyTree, + normalizeAssemblyPath, + parseAssemblyExport, + resolveAssemblyForComponent +} from "../Assemblies/assemblyUtils"; const hiddenHandleStyle = { width: 12, @@ -149,75 +157,6 @@ function renderTreeNodes(nodes = [], map = {}) { )); } -// --- 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.name || s.file_name || part) : 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 }; -} - -// --- Ansible tree helpers (reuse scripts tree builder) --- -function buildAnsibleTree(playbooks, folders) { - return buildScriptTree(playbooks, folders); -} - -// --- 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 normalizeVariableDefinitions(vars = []) { return (Array.isArray(vars) ? vars : []) .map((raw) => { @@ -342,9 +281,12 @@ function ComponentCard({ comp, onRemove, onVariableChange, errors = {} }) { - - {comp.type === "script" ? comp.name : comp.name} - + + + {comp.name} + + {comp.domain ? : null} + {description} @@ -430,6 +372,10 @@ export default function CreateJob({ onCancel, onCreated, initialJob = null }) { const [credentialError, setCredentialError] = useState(""); const [selectedCredentialId, setSelectedCredentialId] = useState(""); const [useSvcAccount, setUseSvcAccount] = useState(true); + const [assembliesPayload, setAssembliesPayload] = useState({ items: [], queue: [] }); + const [assembliesLoading, setAssembliesLoading] = useState(false); + const [assembliesError, setAssembliesError] = useState(""); + const assemblyExportCacheRef = useRef(new Map()); const loadCredentials = useCallback(async () => { setCredentialLoading(true); @@ -449,9 +395,73 @@ export default function CreateJob({ onCancel, onCreated, initialJob = null }) { } }, []); + const loadAssemblies = useCallback(async () => { + setAssembliesLoading(true); + setAssembliesError(""); + try { + const resp = await fetch("/api/assemblies"); + if (!resp.ok) { + const detail = await resp.text(); + throw new Error(detail || `HTTP ${resp.status}`); + } + const data = await resp.json(); + assemblyExportCacheRef.current.clear(); + setAssembliesPayload({ + items: Array.isArray(data?.items) ? data.items : [], + queue: Array.isArray(data?.queue) ? data.queue : [] + }); + } catch (err) { + console.error("Failed to load assemblies:", err); + setAssembliesPayload({ items: [], queue: [] }); + setAssembliesError(err?.message || "Failed to load assemblies"); + } finally { + setAssembliesLoading(false); + } + }, []); + + const assemblyIndex = useMemo( + () => buildAssemblyIndex(assembliesPayload.items, assembliesPayload.queue), + [assembliesPayload.items, assembliesPayload.queue] + ); + const scriptTreeData = useMemo( + () => buildAssemblyTree(assemblyIndex.grouped?.scripts || [], { rootLabel: "Scripts" }), + [assemblyIndex] + ); + const ansibleTreeData = useMemo( + () => buildAssemblyTree(assemblyIndex.grouped?.ansible || [], { rootLabel: "Ansible Playbooks" }), + [assemblyIndex] + ); + const workflowTreeData = useMemo( + () => buildAssemblyTree(assemblyIndex.grouped?.workflows || [], { rootLabel: "Workflows" }), + [assemblyIndex] + ); + + const loadAssemblyExport = useCallback( + async (assemblyGuid) => { + const cacheKey = assemblyGuid.toLowerCase(); + if (assemblyExportCacheRef.current.has(cacheKey)) { + return assemblyExportCacheRef.current.get(cacheKey); + } + const resp = await fetch(`/api/assemblies/${encodeURIComponent(assemblyGuid)}/export`); + if (!resp.ok) { + throw new Error(`Failed to load assembly (HTTP ${resp.status})`); + } + const data = await resp.json(); + assemblyExportCacheRef.current.set(cacheKey, data); + return data; + }, + [] + ); + useEffect(() => { loadCredentials(); }, [loadCredentials]); + useEffect(() => { + loadAssemblies(); + }, [loadAssemblies]); + useEffect(() => { + setSelectedNodeId(""); + }, [compTab]); const remoteExec = useMemo(() => execContext === "ssh" || execContext === "winrm", [execContext]); const handleExecContextChange = useCallback((value) => { @@ -490,9 +500,6 @@ export default function CreateJob({ onCancel, onCreated, initialJob = null }) { // 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 [ansibleTree, setAnsibleTree] = useState([]); const [ansibleMap, setAnsibleMap] = useState({}); const [selectedNodeId, setSelectedNodeId] = useState(""); const [addTargetOpen, setAddTargetOpen] = useState(false); @@ -757,41 +764,6 @@ export default function CreateJob({ onCancel, onCreated, initialJob = null }) { }); return arr; }, [deviceFiltered, deviceOrder, deviceOrderBy]); - - const normalizeComponentPath = useCallback((type, rawPath) => { - const trimmed = (rawPath || "").replace(/\\/g, "/").replace(/^\/+/, "").trim(); - if (!trimmed) return ""; - if (type === "script") { - return trimmed.startsWith("Scripts/") ? trimmed : `Scripts/${trimmed}`; - } - return trimmed; - }, []); - - const fetchAssemblyDoc = useCallback(async (type, rawPath) => { - const normalizedPath = normalizeComponentPath(type, rawPath); - if (!normalizedPath) return { doc: null, normalizedPath: "" }; - const trimmed = normalizedPath.replace(/\\/g, "/").replace(/^\/+/, "").trim(); - if (!trimmed) return { doc: null, normalizedPath: "" }; - let requestPath = trimmed; - if (type === "script" && requestPath.toLowerCase().startsWith("scripts/")) { - requestPath = requestPath.slice("Scripts/".length); - } else if (type === "ansible" && requestPath.toLowerCase().startsWith("ansible_playbooks/")) { - requestPath = requestPath.slice("Ansible_Playbooks/".length); - } - if (!requestPath) return { doc: null, normalizedPath }; - try { - const island = type === "ansible" ? "ansible" : "scripts"; - const resp = await fetch(`/api/assembly/load?island=${island}&path=${encodeURIComponent(requestPath)}`); - if (!resp.ok) { - return { doc: null, normalizedPath }; - } - const data = await resp.json(); - return { doc: data, normalizedPath }; - } catch { - return { doc: null, normalizedPath }; - } - }, [normalizeComponentPath]); - const hydrateExistingComponents = useCallback(async (rawComponents = []) => { const results = []; for (const raw of rawComponents) { @@ -806,24 +778,68 @@ export default function CreateJob({ onCancel, onCreated, initialJob = null }) { }); continue; } - const type = typeRaw === "ansible" ? "ansible" : "script"; - const basePath = raw.path || raw.script_path || raw.rel_path || ""; - const { doc, normalizedPath } = await fetchAssemblyDoc(type, basePath); - const assembly = doc?.assembly || {}; - const docVars = assembly?.variables || doc?.variables || []; + const kind = typeRaw === "ansible" ? "ansible" : "script"; + const assemblyGuidRaw = raw.assembly_guid || raw.assemblyGuid; + let record = null; + if (assemblyGuidRaw) { + const guidKey = String(assemblyGuidRaw).trim().toLowerCase(); + record = assemblyIndex.byGuid?.get(guidKey) || null; + } + if (!record) { + record = resolveAssemblyForComponent(assemblyIndex, raw, kind); + } + if (!record) { + const fallbackPath = + raw.path || + raw.script_path || + raw.playbook_path || + raw.rel_path || + raw.scriptPath || + raw.playbookPath || + ""; + const normalizedFallback = normalizeAssemblyPath( + kind, + fallbackPath, + raw.name || raw.file_name || raw.tab_name || "" + ); + record = assemblyIndex.byPath?.get(normalizedFallback.toLowerCase()) || null; + } + if (!record) { + const mergedFallback = mergeComponentVariables([], raw.variables, raw.variable_values); + results.push({ + ...raw, + type: kind, + path: normalizeAssemblyPath( + kind, + raw.path || raw.script_path || raw.playbook_path || "", + raw.name || raw.file_name || "" + ), + name: raw.name || raw.file_name || raw.tab_name || raw.path || "Assembly", + description: raw.description || raw.path || "", + variables: mergedFallback, + localId: generateLocalId() + }); + continue; + } + const exportDoc = await loadAssemblyExport(record.assemblyGuid); + const parsed = parseAssemblyExport(exportDoc); + const docVars = Array.isArray(parsed.rawVariables) ? parsed.rawVariables : []; const mergedVariables = mergeComponentVariables(docVars, raw.variables, raw.variable_values); results.push({ ...raw, - type, - path: normalizedPath || basePath, - name: raw.name || assembly?.name || raw.file_name || raw.tab_name || normalizedPath || basePath, - description: raw.description || assembly?.description || normalizedPath || basePath, + type: kind, + path: normalizeAssemblyPath(kind, record.path || "", record.displayName), + name: raw.name || record.displayName, + description: raw.description || record.summary || record.path, variables: mergedVariables, - localId: generateLocalId() + localId: generateLocalId(), + assemblyGuid: record.assemblyGuid, + domain: record.domain, + domainLabel: record.domainLabel }); } return results; - }, [fetchAssemblyDoc, generateLocalId]); + }, [assemblyIndex, loadAssemblyExport, mergeComponentVariables, generateLocalId]); const sanitizeComponentsForSave = useCallback((items) => { return (Array.isArray(items) ? items : []).map((comp) => { @@ -1401,67 +1417,52 @@ export default function CreateJob({ onCancel, onCreated, initialJob = null }) { const openAddComponent = async () => { setAddCompOpen(true); - try { - // scripts - const sResp = await fetch("/api/assembly/list?island=scripts"); - if (sResp.ok) { - const sData = await sResp.json(); - const { root, map } = buildScriptTree(sData.items || [], sData.folders || []); - setScriptTree(root); setScriptMap(map); - } else { setScriptTree([]); setScriptMap({}); } - } catch { setScriptTree([]); setScriptMap({}); } - try { - // workflows - const wResp = await fetch("/api/assembly/list?island=workflows"); - if (wResp.ok) { - const wData = await wResp.json(); - const { root, map } = buildWorkflowTree(wData.items || [], wData.folders || []); - setWorkflowTree(root); setWorkflowMap(map); - } else { setWorkflowTree([]); setWorkflowMap({}); } - } catch { setWorkflowTree([]); setWorkflowMap({}); } - try { - // ansible playbooks - const aResp = await fetch("/api/assembly/list?island=ansible"); - if (aResp.ok) { - const aData = await aResp.json(); - const { root, map } = buildAnsibleTree(aData.items || [], aData.folders || []); - setAnsibleTree(root); setAnsibleMap(map); - } else { setAnsibleTree([]); setAnsibleMap({}); } - } catch { setAnsibleTree([]); setAnsibleMap({}); } + setSelectedNodeId(""); + if (!assembliesPayload.items.length && !assembliesLoading) { + loadAssemblies(); + } }; const addSelectedComponent = useCallback(async () => { - const map = compTab === "scripts" ? scriptMap : (compTab === "ansible" ? ansibleMap : workflowMap); - const node = map[selectedNodeId]; + const treeData = + compTab === "ansible" ? ansibleTreeData : compTab === "workflows" ? workflowTreeData : scriptTreeData; + const node = treeData.map[selectedNodeId]; if (!node || node.isFolder) return false; - if (compTab === "workflows" && node.workflow) { + if (compTab === "workflows") { alert("Workflows within Scheduled Jobs are not supported yet"); return false; } - if (compTab === "scripts" || compTab === "ansible") { - const type = compTab === "scripts" ? "script" : "ansible"; - const rawPath = node.path || node.id || ""; - const { doc, normalizedPath } = await fetchAssemblyDoc(type, rawPath); - const assembly = doc?.assembly || {}; - const docVars = assembly?.variables || doc?.variables || []; - const mergedVars = mergeComponentVariables(docVars, [], {}); + const record = node.assembly; + if (!record || !record.assemblyGuid) return false; + try { + const exportDoc = await loadAssemblyExport(record.assemblyGuid); + const parsed = parseAssemblyExport(exportDoc); + const docVars = Array.isArray(parsed.rawVariables) ? parsed.rawVariables : []; + const mergedVariables = mergeComponentVariables(docVars, [], {}); + const type = compTab === "ansible" ? "ansible" : "script"; + const normalizedPath = normalizeAssemblyPath(type, record.path || "", record.displayName); setComponents((prev) => [ ...prev, { type, - path: normalizedPath || rawPath, - name: assembly?.name || node.fileName || node.label, - description: assembly?.description || normalizedPath || rawPath, - variables: mergedVars, - localId: generateLocalId() + path: normalizedPath, + name: record.displayName, + description: record.summary || normalizedPath, + variables: mergedVariables, + localId: generateLocalId(), + assemblyGuid: record.assemblyGuid, + domain: record.domain, + domainLabel: record.domainLabel } ]); setSelectedNodeId(""); return true; + } catch (err) { + console.error("Failed to load assembly export:", err); + alert(err?.message || "Failed to load assembly details."); + return false; } - setSelectedNodeId(""); - return false; - }, [compTab, scriptMap, ansibleMap, workflowMap, selectedNodeId, fetchAssemblyDoc, generateLocalId]); + }, [compTab, selectedNodeId, ansibleTreeData, workflowTreeData, scriptTreeData, loadAssemblyExport, mergeComponentVariables, generateLocalId, normalizeAssemblyPath]); const openAddTargets = async () => { setAddTargetOpen(true); @@ -2006,22 +2007,32 @@ export default function CreateJob({ onCancel, onCreated, initialJob = null }) { sx={{ textTransform: "none", color: "#58a6ff", borderColor: "#58a6ff" }}> Ansible - - + + + {assembliesError ? ( + {assembliesError} + ) : null} {compTab === "scripts" && ( { - const n = scriptMap[id]; + const n = scriptTreeData.map[id]; if (n && !n.isFolder) setSelectedNodeId(id); }}> - {scriptTree.length ? (scriptTree.map((n) => ( - - {n.children && n.children.length ? renderTreeNodes(n.children, scriptMap) : null} - - ))) : ( + {assembliesLoading ? ( + + + Loading assemblies… + + ) : Array.isArray(scriptTreeData.root) && scriptTreeData.root.length ? ( + scriptTreeData.root.map((n) => ( + + {n.children && n.children.length ? renderTreeNodes(n.children, scriptTreeData.map) : null} + + )) + ) : ( No scripts found. )} @@ -2030,14 +2041,21 @@ export default function CreateJob({ onCancel, onCreated, initialJob = null }) { {compTab === "workflows" && ( { - const n = workflowMap[id]; + const n = workflowTreeData.map[id]; if (n && !n.isFolder) setSelectedNodeId(id); }}> - {workflowTree.length ? (workflowTree.map((n) => ( - - {n.children && n.children.length ? renderTreeNodes(n.children, workflowMap) : null} - - ))) : ( + {assembliesLoading ? ( + + + Loading assemblies… + + ) : Array.isArray(workflowTreeData.root) && workflowTreeData.root.length ? ( + workflowTreeData.root.map((n) => ( + + {n.children && n.children.length ? renderTreeNodes(n.children, workflowTreeData.map) : null} + + )) + ) : ( No workflows found. )} @@ -2046,14 +2064,21 @@ export default function CreateJob({ onCancel, onCreated, initialJob = null }) { {compTab === "ansible" && ( { - const n = ansibleMap[id]; + const n = ansibleTreeData.map[id]; if (n && !n.isFolder) setSelectedNodeId(id); }}> - {ansibleTree.length ? (ansibleTree.map((n) => ( - - {n.children && n.children.length ? renderTreeNodes(n.children, ansibleMap) : null} - - ))) : ( + {assembliesLoading ? ( + + + Loading assemblies… + + ) : Array.isArray(ansibleTreeData.root) && ansibleTreeData.root.length ? ( + ansibleTreeData.root.map((n) => ( + + {n.children && n.children.length ? renderTreeNodes(n.children, ansibleTreeData.map) : null} + + )) + ) : ( No playbooks found. )} @@ -2139,3 +2164,6 @@ export default function CreateJob({ onCancel, onCreated, initialJob = null }) { ); } + + + diff --git a/Data/Engine/web-interface/src/Scheduling/Quick_Job.jsx b/Data/Engine/web-interface/src/Scheduling/Quick_Job.jsx index e2e16737..71446bd6 100644 --- a/Data/Engine/web-interface/src/Scheduling/Quick_Job.jsx +++ b/Data/Engine/web-interface/src/Scheduling/Quick_Job.jsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState, useCallback } from "react"; +import React, { useEffect, useState, useCallback, useMemo, useRef } from "react"; import { Dialog, DialogTitle, @@ -19,66 +19,19 @@ import { } from "@mui/material"; import { Folder as FolderIcon, Description as DescriptionIcon } from "@mui/icons-material"; import { SimpleTreeView, TreeItem } from "@mui/x-tree-view"; - -function buildTree(items, rootLabel = "Scripts") { - const map = {}; - const rootNode = { - id: "root", - label: rootLabel, - path: "", - isFolder: true, - children: [] - }; - map[rootNode.id] = rootNode; - - (items || []).forEach((item) => { - if (!item || typeof item !== "object") return; - const metadata = item.metadata && typeof item.metadata === "object" ? item.metadata : {}; - const rawPath = String(metadata.source_path || metadata.legacy_path || "") - .replace(/\\/g, "/") - .replace(/^\/+/, "") - .trim(); - const pathSegments = rawPath ? rawPath.split("/").filter(Boolean) : []; - const segments = pathSegments.length - ? pathSegments - : [String(item.display_name || metadata.display_name || item.assembly_guid || "Assembly").trim() || "Assembly"]; - let children = rootNode.children; - let parentPath = ""; - segments.forEach((segment, idx) => { - const nodeId = parentPath ? `${parentPath}/${segment}` : segment; - const isFile = idx === segments.length - 1; - let node = children.find((n) => n.id === nodeId); - if (!node) { - node = { - id: nodeId, - label: isFile ? (item.display_name || metadata.display_name || segment) : segment, - path: nodeId, - isFolder: !isFile, - script: isFile ? item : null, - scriptPath: isFile ? (rawPath || nodeId) : undefined, - children: [] - }; - children.push(node); - map[nodeId] = node; - } else if (isFile) { - node.script = item; - node.label = item.display_name || metadata.display_name || node.label; - node.scriptPath = rawPath || nodeId; - } - if (!isFile) { - children = node.children; - parentPath = nodeId; - } - }); - }); - - return { root: [rootNode], map }; -} +import { DomainBadge } from "../Assemblies/Assembly_Badges"; +import { + buildAssemblyIndex, + buildAssemblyTree, + normalizeAssemblyPath, + parseAssemblyExport +} from "../Assemblies/assemblyUtils"; export default function QuickJob({ open, onClose, hostnames = [] }) { - const [tree, setTree] = useState([]); - const [nodeMap, setNodeMap] = useState({}); - const [selectedPath, setSelectedPath] = useState(""); + const [assemblyPayload, setAssemblyPayload] = useState({ items: [], queue: [] }); + const [assembliesLoading, setAssembliesLoading] = useState(false); + const [assembliesError, setAssembliesError] = useState(""); + const [selectedAssemblyGuid, setSelectedAssemblyGuid] = useState(""); const [running, setRunning] = useState(false); const [error, setError] = useState(""); const [runAsCurrentUser, setRunAsCurrentUser] = useState(false); @@ -92,44 +45,96 @@ export default function QuickJob({ open, onClose, hostnames = [] }) { const [variableValues, setVariableValues] = useState({}); const [variableErrors, setVariableErrors] = useState({}); const [variableStatus, setVariableStatus] = useState({ loading: false, error: "" }); + const assemblyExportCacheRef = useRef(new Map()); - const loadTree = useCallback(async () => { + const loadAssemblies = useCallback(async () => { + setAssembliesLoading(true); + setAssembliesError(""); try { const resp = await fetch("/api/assemblies"); - if (!resp.ok) throw new Error(`HTTP ${resp.status}`); + if (!resp.ok) { + const detail = await resp.text(); + throw new Error(detail || `HTTP ${resp.status}`); + } const data = await resp.json(); - const items = Array.isArray(data?.items) ? data.items : []; - const filtered = items.filter((item) => { - const kind = String(item?.assembly_kind || "").toLowerCase(); - const type = String(item?.assembly_type || "").toLowerCase(); - if (mode === "ansible") { - return type === "ansible"; - } - return kind === "script" && type !== "ansible"; + assemblyExportCacheRef.current.clear(); + setAssemblyPayload({ + items: Array.isArray(data?.items) ? data.items : [], + queue: Array.isArray(data?.queue) ? data.queue : [] }); - const { root, map } = buildTree(filtered, mode === "ansible" ? "Ansible Playbooks" : "Scripts"); - setTree(root); - setNodeMap(map); } catch (err) { - console.error("Failed to load scripts:", err); - setTree([]); - setNodeMap({}); + console.error("Failed to load assemblies:", err); + setAssemblyPayload({ items: [], queue: [] }); + setAssembliesError(err?.message || "Failed to load assemblies"); + } finally { + setAssembliesLoading(false); } - }, [mode]); + }, []); + + const assemblyIndex = useMemo( + () => buildAssemblyIndex(assemblyPayload.items, assemblyPayload.queue), + [assemblyPayload.items, assemblyPayload.queue] + ); + + const scriptTreeData = useMemo( + () => buildAssemblyTree(assemblyIndex.grouped?.scripts || [], { rootLabel: "Scripts" }), + [assemblyIndex] + ); + + const ansibleTreeData = useMemo( + () => buildAssemblyTree(assemblyIndex.grouped?.ansible || [], { rootLabel: "Ansible Playbooks" }), + [assemblyIndex] + ); + + const selectedAssembly = useMemo(() => { + if (!selectedAssemblyGuid) return null; + const guid = selectedAssemblyGuid.toLowerCase(); + return assemblyIndex.byGuid?.get(guid) || null; + }, [selectedAssemblyGuid, assemblyIndex]); + + const loadAssemblyExport = useCallback( + async (assemblyGuid) => { + const cacheKey = assemblyGuid.toLowerCase(); + if (assemblyExportCacheRef.current.has(cacheKey)) { + return assemblyExportCacheRef.current.get(cacheKey); + } + const resp = await fetch(`/api/assemblies/${encodeURIComponent(assemblyGuid)}/export`); + if (!resp.ok) { + throw new Error(`Failed to load assembly (HTTP ${resp.status})`); + } + const data = await resp.json(); + assemblyExportCacheRef.current.set(cacheKey, data); + return data; + }, + [] + ); useEffect(() => { - if (open) { - setSelectedPath(""); - setError(""); - setVariables([]); - setVariableValues({}); - setVariableErrors({}); - setVariableStatus({ loading: false, error: "" }); - setUseSvcAccount(true); - setSelectedCredentialId(""); - loadTree(); + if (!open) { + setSelectedAssemblyGuid(""); + return; } - }, [open, loadTree]); + setSelectedAssemblyGuid(""); + setError(""); + setVariables([]); + setVariableValues({}); + setVariableErrors({}); + setVariableStatus({ loading: false, error: "" }); + setUseSvcAccount(true); + setSelectedCredentialId(""); + if (!assemblyPayload.items.length && !assembliesLoading) { + loadAssemblies(); + } + }, [open, loadAssemblies, assemblyPayload.items.length, assembliesLoading]); + + useEffect(() => { + if (!open) return; + setSelectedAssemblyGuid(""); + setVariables([]); + setVariableValues({}); + setVariableErrors({}); + setVariableStatus({ loading: false, error: "" }); + }, [mode, open]); useEffect(() => { if (!open || mode !== "ansible") return; @@ -201,37 +206,18 @@ export default function QuickJob({ open, onClose, hostnames = [] }) { )); - const onItemSelect = (_e, itemId) => { - const node = nodeMap[itemId]; - if (node && !node.isFolder) { - setSelectedPath(node.path); - setError(""); - setVariableErrors({}); - } - }; - - const normalizeVariables = (list) => { - if (!Array.isArray(list)) return []; - return list - .map((raw) => { - if (!raw || typeof raw !== "object") return null; - const name = typeof raw.name === "string" ? raw.name.trim() : typeof raw.key === "string" ? raw.key.trim() : ""; - if (!name) return null; - const type = typeof raw.type === "string" ? raw.type.toLowerCase() : "string"; - const label = typeof raw.label === "string" && raw.label.trim() ? raw.label.trim() : name; - const description = typeof raw.description === "string" ? raw.description : ""; - const required = Boolean(raw.required); - const defaultValue = raw.hasOwnProperty("default") - ? raw.default - : raw.hasOwnProperty("defaultValue") - ? raw.defaultValue - : raw.hasOwnProperty("default_value") - ? raw.default_value - : ""; - return { name, label, type, description, required, default: defaultValue }; - }) - .filter(Boolean); - }; + const onItemSelect = useCallback( + (_e, itemId) => { + const treeData = mode === "ansible" ? ansibleTreeData : scriptTreeData; + const node = treeData.map[itemId]; + if (node && !node.isFolder && node.assemblyGuid) { + setSelectedAssemblyGuid(node.assemblyGuid); + setError(""); + setVariableErrors({}); + } + }, + [mode, ansibleTreeData, scriptTreeData] + ); const deriveInitialValue = (variable) => { const { type, default: defaultValue } = variable; @@ -254,7 +240,7 @@ export default function QuickJob({ open, onClose, hostnames = [] }) { }; useEffect(() => { - if (!selectedPath) { + if (!selectedAssemblyGuid) { setVariables([]); setVariableValues({}); setVariableErrors({}); @@ -262,61 +248,34 @@ export default function QuickJob({ open, onClose, hostnames = [] }) { return; } let canceled = false; - const loadAssembly = async () => { + (async () => { setVariableStatus({ loading: true, error: "" }); try { - const trimmed = (selectedPath || "").replace(/\\/g, "/").replace(/^\/+/, "").trim(); - if (!trimmed) { - setVariables([]); - setVariableValues({}); - setVariableErrors({}); - setVariableStatus({ loading: false, error: "" }); - return; - } - const node = nodeMap[trimmed]; - const script = node?.script; - const assemblyGuid = script?.assembly_guid; - if (!assemblyGuid) { - setVariables([]); - setVariableValues({}); - setVariableErrors({}); - setVariableStatus({ loading: false, error: "" }); - return; - } - const resp = await fetch(`/api/assemblies/${encodeURIComponent(assemblyGuid)}/export`); - if (!resp.ok) throw new Error(`Failed to load assembly (HTTP ${resp.status})`); - const data = await resp.json(); - const metadata = data?.metadata && typeof data.metadata === "object" ? data.metadata : {}; - const payload = data?.payload && typeof data.payload === "object" ? data.payload : {}; - const varsSource = - (payload && payload.variables) || - metadata.variables || - []; - const defs = normalizeVariables(varsSource); - if (!canceled) { - setVariables(defs); - const initialValues = {}; - defs.forEach((v) => { - initialValues[v.name] = deriveInitialValue(v); - }); - setVariableValues(initialValues); - setVariableErrors({}); - setVariableStatus({ loading: false, error: "" }); - } + const exportDoc = await loadAssemblyExport(selectedAssemblyGuid); + if (canceled) return; + const parsed = parseAssemblyExport(exportDoc); + const defs = Array.isArray(parsed.variables) ? parsed.variables : []; + setVariables(defs); + const initialValues = {}; + defs.forEach((v) => { + if (!v || !v.name) return; + initialValues[v.name] = deriveInitialValue(v); + }); + setVariableValues(initialValues); + setVariableErrors({}); + setVariableStatus({ loading: false, error: "" }); } catch (err) { - if (!canceled) { - setVariables([]); - setVariableValues({}); - setVariableErrors({}); - setVariableStatus({ loading: false, error: err?.message || String(err) }); - } + if (canceled) return; + setVariables([]); + setVariableValues({}); + setVariableErrors({}); + setVariableStatus({ loading: false, error: err?.message || String(err) }); } - }; - loadAssembly(); + })(); return () => { canceled = true; }; - }, [selectedPath, mode, nodeMap]); + }, [selectedAssemblyGuid, loadAssemblyExport]); const handleVariableChange = (variable, rawValue) => { const { name, type } = variable; @@ -357,11 +316,11 @@ export default function QuickJob({ open, onClose, hostnames = [] }) { }; const onRun = async () => { - if (!selectedPath) { - setError(mode === 'ansible' ? "Please choose a playbook to run." : "Please choose a script to run."); + if (!selectedAssembly) { + setError(mode === "ansible" ? "Please choose a playbook to run." : "Please choose a script to run."); return; } - if (mode === 'ansible' && !useSvcAccount && !selectedCredentialId) { + if (mode === "ansible" && !useSvcAccount && !selectedCredentialId) { setError("Select a credential to run this playbook."); return; } @@ -388,17 +347,17 @@ export default function QuickJob({ open, onClose, hostnames = [] }) { try { let resp; const variableOverrides = buildVariablePayload(); - const node = nodeMap[selectedPath]; - if (mode === 'ansible') { - const rawPath = (node?.scriptPath || selectedPath || "").replace(/\\/g, "/"); - const playbook_path = rawPath.toLowerCase().startsWith("ansible_playbooks/") - ? rawPath - : `Ansible_Playbooks/${rawPath}`; + const normalizedPath = normalizeAssemblyPath( + mode === "ansible" ? "ansible" : "script", + selectedAssembly.path || "", + selectedAssembly.displayName + ); + if (mode === "ansible") { resp = await fetch("/api/ansible/quick_run", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ - playbook_path, + playbook_path: normalizedPath, hostnames, variable_values: variableOverrides, credential_id: !useSvcAccount && selectedCredentialId ? Number(selectedCredentialId) : null, @@ -406,21 +365,31 @@ export default function QuickJob({ open, onClose, hostnames = [] }) { }) }); } else { - const rawPath = (node?.scriptPath || selectedPath || "").replace(/\\/g, "/"); - const script_path = rawPath.toLowerCase().startsWith("scripts/") ? rawPath : `Scripts/${rawPath}`; resp = await fetch("/api/scripts/quick_run", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ - script_path, + script_path: normalizedPath, hostnames, run_mode: runAsCurrentUser ? "current_user" : "system", variable_values: variableOverrides }) }); } - const data = await resp.json(); - if (!resp.ok) throw new Error(data.error || `HTTP ${resp.status}`); + const contentType = String(resp.headers.get("content-type") || ""); + let data = null; + if (contentType.includes("application/json")) { + data = await resp.json().catch(() => null); + } else { + const text = await resp.text().catch(() => ""); + if (text && text.trim()) { + data = { error: text.trim() }; + } + } + if (!resp.ok) { + const message = data?.error || data?.message || `HTTP ${resp.status}`; + throw new Error(message); + } onClose && onClose(); } catch (err) { setError(String(err.message || err)); @@ -432,8 +401,10 @@ export default function QuickJob({ open, onClose, hostnames = [] }) { const credentialRequired = mode === "ansible" && !useSvcAccount; const disableRun = running || - !selectedPath || + !selectedAssembly || (credentialRequired && (!selectedCredentialId || !credentials.length)); + const activeTreeData = mode === "ansible" ? ansibleTreeData : scriptTreeData; + const treeItems = Array.isArray(activeTreeData.root) ? activeTreeData.root : []; return ( Select a {mode === 'ansible' ? 'playbook' : 'script'} to run on {hostnames.length} device{hostnames.length !== 1 ? "s" : ""}. + {assembliesError ? ( + {assembliesError} + ) : null} {mode === 'ansible' && ( - {tree.length ? renderNodes(tree) : ( + {assembliesLoading ? ( + + + Loading assemblies… + + ) : treeItems.length ? ( + renderNodes(treeItems) + ) : ( {mode === 'ansible' ? 'No playbooks found.' : 'No scripts found.'} @@ -520,9 +501,19 @@ export default function QuickJob({ open, onClose, hostnames = [] }) { Selection - - {selectedPath || (mode === 'ansible' ? 'No playbook selected' : 'No script selected')} - + {selectedAssembly ? ( + + + {selectedAssembly.displayName} + + + {selectedAssembly.path} + + ) : ( + + {mode === 'ansible' ? 'No playbook selected' : 'No script selected'} + + )} {mode !== 'ansible' && ( <> diff --git a/Data/Engine/web-interface/src/Scheduling/Scheduled_Jobs_List.jsx b/Data/Engine/web-interface/src/Scheduling/Scheduled_Jobs_List.jsx index 7e22a10e..6f92ac84 100644 --- a/Data/Engine/web-interface/src/Scheduling/Scheduled_Jobs_List.jsx +++ b/Data/Engine/web-interface/src/Scheduling/Scheduled_Jobs_List.jsx @@ -20,6 +20,8 @@ import { } from "@mui/material"; import { AgGridReact } from "ag-grid-react"; import { ModuleRegistry, AllCommunityModule, themeQuartz } from "ag-grid-community"; +import { DomainBadge, resolveDomainMeta } from "../Assemblies/Assembly_Badges"; +import { buildAssemblyIndex, resolveAssemblyForComponent } from "../Assemblies/assemblyUtils"; ModuleRegistry.registerModules([AllCommunityModule]); @@ -143,8 +145,56 @@ export default function ScheduledJobsList({ onCreateJob, onEditJob, refreshToken const [error, setError] = useState(""); const [bulkDeleteOpen, setBulkDeleteOpen] = useState(false); const [selectedIds, setSelectedIds] = useState(() => new Set()); + const [assembliesPayload, setAssembliesPayload] = useState({ items: [], queue: [] }); + const [assembliesLoading, setAssembliesLoading] = useState(false); + const [assembliesError, setAssembliesError] = useState(""); const gridApiRef = useRef(null); + const assembliesCellRenderer = useCallback((params) => { + const list = params?.data?.componentsMeta || []; + if (!list.length) { + return No assemblies; + } + return ( + + {list.map((item) => ( + + + {item.label} + + ))} + + ); + }, []); + + const loadAssemblies = useCallback(async () => { + setAssembliesLoading(true); + setAssembliesError(""); + try { + const resp = await fetch("/api/assemblies"); + if (!resp.ok) { + const detail = await resp.text(); + throw new Error(detail || `HTTP ${resp.status}`); + } + const data = await resp.json(); + setAssembliesPayload({ + items: Array.isArray(data?.items) ? data.items : [], + queue: Array.isArray(data?.queue) ? data.queue : [] + }); + } catch (err) { + console.error("Failed to load assemblies:", err); + setAssembliesPayload({ items: [], queue: [] }); + setAssembliesError(err?.message || "Failed to load assemblies"); + } finally { + setAssembliesLoading(false); + } + }, []); + + const assemblyIndex = useMemo( + () => buildAssemblyIndex(assembliesPayload.items, assembliesPayload.queue), + [assembliesPayload.items, assembliesPayload.queue] + ); + const loadJobs = useCallback( async ({ showLoading = false } = {}) => { if (showLoading) { @@ -196,7 +246,44 @@ export default function ScheduledJobsList({ onCreateJob, onEditJob, refreshToken } }; const mappedRows = (data?.jobs || []).map((j) => { - const compName = (Array.isArray(j.components) && j.components[0]?.name) || "Demonstration Component"; + const components = Array.isArray(j.components) ? j.components : []; + const normalizedComponents = components.map((component) => { + const record = resolveAssemblyForComponent(assemblyIndex, component); + const displayName = + record?.displayName || + component.name || + component.component_name || + component.script_name || + component.script_path || + component.path || + "Assembly"; + const domainValue = (record?.domain || component.domain || "user").toLowerCase(); + const domainMeta = resolveDomainMeta(domainValue); + const assemblyGuid = + component.assembly_guid || + component.assemblyGuid || + record?.assemblyGuid || + null; + return { + ...component, + assembly_guid: assemblyGuid, + name: displayName, + domain: domainValue, + domainLabel: domainMeta.label, + path: record?.path || component.path || component.script_path || component.playbook_path || "" + }; + }); + const componentSummaries = normalizedComponents.map((comp, idx) => ({ + key: `${comp.assembly_guid || comp.path || idx}-${idx}`, + label: comp.name, + domain: comp.domain + })); + const compName = + componentSummaries.length === 1 + ? componentSummaries[0].label + : componentSummaries.length > 1 + ? `${componentSummaries.length} Assemblies` + : "No Assemblies"; const targetText = Array.isArray(j.targets) ? `${j.targets.length} device${j.targets.length !== 1 ? "s" : ""}` : ""; @@ -213,6 +300,7 @@ export default function ScheduledJobsList({ onCreateJob, onEditJob, refreshToken id: j.id, name: j.name, scriptWorkflow: compName, + componentsMeta: componentSummaries, target: targetText, occurrence, lastRun: fmt(j.last_run_ts), @@ -220,7 +308,7 @@ export default function ScheduledJobsList({ onCreateJob, onEditJob, refreshToken result: j.last_status || (j.next_run_ts ? "Scheduled" : ""), resultsCounts, enabled: Boolean(j.enabled), - raw: j + raw: { ...j, components: normalizedComponents } }; }); setRows(mappedRows); @@ -251,9 +339,13 @@ export default function ScheduledJobsList({ onCreateJob, onEditJob, refreshToken } } }, - [] + [assemblyIndex] ); + useEffect(() => { + loadAssemblies(); + }, [loadAssemblies, refreshToken]); + useEffect(() => { let timer; let isMounted = true; @@ -422,8 +514,9 @@ export default function ScheduledJobsList({ onCreateJob, onEditJob, refreshToken }, { headerName: "Assembly(s)", - field: "scriptWorkflow", - valueGetter: (params) => params.data?.scriptWorkflow || "Demonstration Component" + field: "componentsMeta", + minWidth: 240, + cellRenderer: assembliesCellRenderer }, { headerName: "Target", @@ -461,7 +554,7 @@ export default function ScheduledJobsList({ onCreateJob, onEditJob, refreshToken suppressMenu: true } ], - [enabledCellRenderer, nameCellRenderer, resultsCellRenderer] + [enabledCellRenderer, nameCellRenderer, resultsCellRenderer, assembliesCellRenderer] ); const defaultColDef = useMemo(