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

Binary file not shown.

View File

Binary file not shown.

View File

Binary file not shown.

View File

@@ -36,6 +36,7 @@ from .tokens import routes as token_routes
from ...server import EngineContext
from .access_management.login import register_auth
from .assemblies.management import register_assemblies
from .assemblies.execution import register_execution
from .devices import routes as device_routes
from .devices.approval import register_admin_endpoints
from .devices.management import register_management
@@ -268,12 +269,17 @@ def _register_scheduled_jobs(app: Flask, adapters: EngineServiceAdapters) -> 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,
}

View File

@@ -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") {

View File

@@ -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;
}

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(