mirror of
https://github.com/bunny-lab-io/Borealis.git
synced 2025-12-15 00:35:47 -07:00
Assembly Execution (SYSTEM & CURRENTUSER) Resolved
This commit is contained in:
BIN
Data/Engine/Assemblies/community.db-shm
Normal file
BIN
Data/Engine/Assemblies/community.db-shm
Normal file
Binary file not shown.
0
Data/Engine/Assemblies/community.db-wal
Normal file
0
Data/Engine/Assemblies/community.db-wal
Normal file
BIN
Data/Engine/Assemblies/official.db-shm
Normal file
BIN
Data/Engine/Assemblies/official.db-shm
Normal file
Binary file not shown.
0
Data/Engine/Assemblies/official.db-wal
Normal file
0
Data/Engine/Assemblies/official.db-wal
Normal file
BIN
Data/Engine/Assemblies/user_created.db-shm
Normal file
BIN
Data/Engine/Assemblies/user_created.db-shm
Normal file
Binary file not shown.
0
Data/Engine/Assemblies/user_created.db-wal
Normal file
0
Data/Engine/Assemblies/user_created.db-wal
Normal 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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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") {
|
||||
|
||||
541
Data/Engine/web-interface/src/Assemblies/assemblyUtils.js
Normal file
541
Data/Engine/web-interface/src/Assemblies/assemblyUtils.js
Normal 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;
|
||||
}
|
||||
@@ -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 }}>
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
|
||||
<Typography variant="subtitle2" sx={{ color: "#e6edf3" }}>
|
||||
{comp.type === "script" ? comp.name : comp.name}
|
||||
{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;
|
||||
}
|
||||
setSelectedNodeId("");
|
||||
} catch (err) {
|
||||
console.error("Failed to load assembly export:", err);
|
||||
alert(err?.message || "Failed to load assembly details.");
|
||||
return false;
|
||||
}, [compTab, scriptMap, ansibleMap, workflowMap, selectedNodeId, fetchAssemblyDoc, generateLocalId]);
|
||||
}
|
||||
}, [compTab, selectedNodeId, ansibleTreeData, workflowTreeData, scriptTreeData, loadAssemblyExport, mergeComponentVariables, generateLocalId, normalizeAssemblyPath]);
|
||||
|
||||
const openAddTargets = async () => {
|
||||
setAddTargetOpen(true);
|
||||
@@ -2011,17 +2012,27 @@ export default function CreateJob({ onCancel, onCreated, initialJob = null }) {
|
||||
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) => (
|
||||
{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, scriptMap) : null}
|
||||
{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) => (
|
||||
{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, workflowMap) : null}
|
||||
{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) => (
|
||||
{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, ansibleMap) : null}
|
||||
{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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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,34 +45,76 @@ 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("");
|
||||
if (!open) {
|
||||
setSelectedAssemblyGuid("");
|
||||
return;
|
||||
}
|
||||
setSelectedAssemblyGuid("");
|
||||
setError("");
|
||||
setVariables([]);
|
||||
setVariableValues({});
|
||||
@@ -127,9 +122,19 @@ export default function QuickJob({ open, onClose, hostnames = [] }) {
|
||||
setVariableStatus({ loading: false, error: "" });
|
||||
setUseSvcAccount(true);
|
||||
setSelectedCredentialId("");
|
||||
loadTree();
|
||||
if (!assemblyPayload.items.length && !assembliesLoading) {
|
||||
loadAssemblies();
|
||||
}
|
||||
}, [open, loadTree]);
|
||||
}, [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);
|
||||
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({});
|
||||
}
|
||||
};
|
||||
|
||||
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);
|
||||
};
|
||||
},
|
||||
[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) {
|
||||
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) {
|
||||
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')}
|
||||
{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' && (
|
||||
<>
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user