Assembly Execution (SYSTEM & CURRENTUSER) Resolved

This commit is contained in:
2025-11-03 23:45:54 -07:00
parent 1e224532db
commit deeacb148e
12 changed files with 1047 additions and 457 deletions

View File

@@ -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 = {} }) {
<Paper sx={{ bgcolor: "#2a2a2a", border: "1px solid #3a3a3a", p: 1.2, mb: 1.2, borderRadius: 1 }}>
<Box sx={{ display: "flex", gap: 2 }}>
<Box sx={{ flex: 1 }}>
<Typography variant="subtitle2" sx={{ color: "#e6edf3" }}>
{comp.type === "script" ? comp.name : comp.name}
</Typography>
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
<Typography variant="subtitle2" sx={{ color: "#e6edf3" }}>
{comp.name}
</Typography>
{comp.domain ? <DomainBadge domain={comp.domain} size="small" /> : null}
</Box>
<Typography variant="body2" sx={{ color: "#aaa" }}>
{description}
</Typography>
@@ -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
</Button>
<Button size="small" variant={compTab === "workflows" ? "outlined" : "text"} onClick={() => setCompTab("workflows")}
sx={{ textTransform: "none", color: "#58a6ff", borderColor: "#58a6ff" }}>
Workflows
</Button>
</Box>
<Button size="small" variant={compTab === "workflows" ? "outlined" : "text"} onClick={() => setCompTab("workflows")}
sx={{ textTransform: "none", color: "#58a6ff", borderColor: "#58a6ff" }}>
Workflows
</Button>
</Box>
{assembliesError ? (
<Typography variant="body2" sx={{ color: "#ff8080", mb: 1 }}>{assembliesError}</Typography>
) : null}
{compTab === "scripts" && (
<Paper sx={{ p: 1, bgcolor: "#1e1e1e", maxHeight: 400, overflow: "auto" }}>
<SimpleTreeView onItemSelectionToggle={(_, id) => {
const n = scriptMap[id];
const n = scriptTreeData.map[id];
if (n && !n.isFolder) setSelectedNodeId(id);
}}>
{scriptTree.length ? (scriptTree.map((n) => (
<TreeItem key={n.id} itemId={n.id} label={n.label}>
{n.children && n.children.length ? renderTreeNodes(n.children, scriptMap) : null}
</TreeItem>
))) : (
{assembliesLoading ? (
<Box sx={{ display: "flex", alignItems: "center", gap: 1, px: 1, py: 0.5, color: "#7db7ff" }}>
<CircularProgress size={18} sx={{ color: "#58a6ff" }} />
<Typography variant="body2">Loading assemblies</Typography>
</Box>
) : Array.isArray(scriptTreeData.root) && scriptTreeData.root.length ? (
scriptTreeData.root.map((n) => (
<TreeItem key={n.id} itemId={n.id} label={n.label}>
{n.children && n.children.length ? renderTreeNodes(n.children, scriptTreeData.map) : null}
</TreeItem>
))
) : (
<Typography variant="body2" sx={{ color: "#888", p: 1 }}>No scripts found.</Typography>
)}
</SimpleTreeView>
@@ -2030,14 +2041,21 @@ export default function CreateJob({ onCancel, onCreated, initialJob = null }) {
{compTab === "workflows" && (
<Paper sx={{ p: 1, bgcolor: "#1e1e1e", maxHeight: 400, overflow: "auto" }}>
<SimpleTreeView onItemSelectionToggle={(_, id) => {
const n = workflowMap[id];
const n = workflowTreeData.map[id];
if (n && !n.isFolder) setSelectedNodeId(id);
}}>
{workflowTree.length ? (workflowTree.map((n) => (
<TreeItem key={n.id} itemId={n.id} label={n.label}>
{n.children && n.children.length ? renderTreeNodes(n.children, workflowMap) : null}
</TreeItem>
))) : (
{assembliesLoading ? (
<Box sx={{ display: "flex", alignItems: "center", gap: 1, px: 1, py: 0.5, color: "#7db7ff" }}>
<CircularProgress size={18} sx={{ color: "#58a6ff" }} />
<Typography variant="body2">Loading assemblies</Typography>
</Box>
) : Array.isArray(workflowTreeData.root) && workflowTreeData.root.length ? (
workflowTreeData.root.map((n) => (
<TreeItem key={n.id} itemId={n.id} label={n.label}>
{n.children && n.children.length ? renderTreeNodes(n.children, workflowTreeData.map) : null}
</TreeItem>
))
) : (
<Typography variant="body2" sx={{ color: "#888", p: 1 }}>No workflows found.</Typography>
)}
</SimpleTreeView>
@@ -2046,14 +2064,21 @@ export default function CreateJob({ onCancel, onCreated, initialJob = null }) {
{compTab === "ansible" && (
<Paper sx={{ p: 1, bgcolor: "#1e1e1e", maxHeight: 400, overflow: "auto" }}>
<SimpleTreeView onItemSelectionToggle={(_, id) => {
const n = ansibleMap[id];
const n = ansibleTreeData.map[id];
if (n && !n.isFolder) setSelectedNodeId(id);
}}>
{ansibleTree.length ? (ansibleTree.map((n) => (
<TreeItem key={n.id} itemId={n.id} label={n.label}>
{n.children && n.children.length ? renderTreeNodes(n.children, ansibleMap) : null}
</TreeItem>
))) : (
{assembliesLoading ? (
<Box sx={{ display: "flex", alignItems: "center", gap: 1, px: 1, py: 0.5, color: "#7db7ff" }}>
<CircularProgress size={18} sx={{ color: "#58a6ff" }} />
<Typography variant="body2">Loading assemblies</Typography>
</Box>
) : Array.isArray(ansibleTreeData.root) && ansibleTreeData.root.length ? (
ansibleTreeData.root.map((n) => (
<TreeItem key={n.id} itemId={n.id} label={n.label}>
{n.children && n.children.length ? renderTreeNodes(n.children, ansibleTreeData.map) : null}
</TreeItem>
))
) : (
<Typography variant="body2" sx={{ color: "#888", p: 1 }}>No playbooks found.</Typography>
)}
</SimpleTreeView>
@@ -2139,3 +2164,6 @@ export default function CreateJob({ onCancel, onCreated, initialJob = null }) {
</Paper>
);
}

View File

@@ -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 = [] }) {
</TreeItem>
));
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 (
<Dialog open={open} onClose={running ? undefined : onClose} fullWidth maxWidth="md"
@@ -448,6 +419,9 @@ export default function QuickJob({ open, onClose, hostnames = [] }) {
<Typography variant="body2" sx={{ color: "#aaa", mb: 1 }}>
Select a {mode === 'ansible' ? 'playbook' : 'script'} to run on {hostnames.length} device{hostnames.length !== 1 ? "s" : ""}.
</Typography>
{assembliesError ? (
<Typography variant="body2" sx={{ color: "#ff8080", mb: 1 }}>{assembliesError}</Typography>
) : null}
{mode === 'ansible' && (
<Box sx={{ display: "flex", alignItems: "center", gap: 1.5, flexWrap: "wrap", mb: 2 }}>
<FormControlLabel
@@ -511,7 +485,14 @@ export default function QuickJob({ open, onClose, hostnames = [] }) {
<Box sx={{ display: "flex", gap: 2 }}>
<Paper sx={{ flex: 1, p: 1, bgcolor: "#1e1e1e", maxHeight: 400, overflow: "auto" }}>
<SimpleTreeView sx={{ color: "#e6edf3" }} onItemSelectionToggle={onItemSelect}>
{tree.length ? renderNodes(tree) : (
{assembliesLoading ? (
<Box sx={{ display: "flex", alignItems: "center", gap: 1, px: 1, py: 0.5, color: "#7db7ff" }}>
<CircularProgress size={18} sx={{ color: "#58a6ff" }} />
<Typography variant="body2">Loading assemblies</Typography>
</Box>
) : treeItems.length ? (
renderNodes(treeItems)
) : (
<Typography variant="body2" sx={{ color: "#888", p: 1 }}>
{mode === 'ansible' ? 'No playbooks found.' : 'No scripts found.'}
</Typography>
@@ -520,9 +501,19 @@ export default function QuickJob({ open, onClose, hostnames = [] }) {
</Paper>
<Box sx={{ width: 320 }}>
<Typography variant="subtitle2" sx={{ color: "#ccc", mb: 1 }}>Selection</Typography>
<Typography variant="body2" sx={{ color: selectedPath ? "#e6edf3" : "#888" }}>
{selectedPath || (mode === 'ansible' ? 'No playbook selected' : 'No script selected')}
</Typography>
{selectedAssembly ? (
<Box sx={{ display: "flex", flexDirection: "column", gap: 0.5 }}>
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
<Typography variant="body2" sx={{ color: "#e6edf3" }}>{selectedAssembly.displayName}</Typography>
<DomainBadge domain={selectedAssembly.domain} size="small" />
</Box>
<Typography variant="body2" sx={{ color: "#aaa" }}>{selectedAssembly.path}</Typography>
</Box>
) : (
<Typography variant="body2" sx={{ color: "#888" }}>
{mode === 'ansible' ? 'No playbook selected' : 'No script selected'}
</Typography>
)}
<Box sx={{ mt: 2 }}>
{mode !== 'ansible' && (
<>

View File

@@ -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 <Typography variant="body2" sx={{ color: "#888" }}>No assemblies</Typography>;
}
return (
<Box sx={{ display: "flex", flexWrap: "wrap", gap: 1 }}>
{list.map((item) => (
<Box key={item.key} sx={{ display: "flex", alignItems: "center", gap: 0.75 }}>
<DomainBadge domain={item.domain} size="small" />
<Typography variant="body2" sx={{ color: "#f5f7fa" }}>{item.label}</Typography>
</Box>
))}
</Box>
);
}, []);
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(