import React, { useEffect, useState, useCallback, useMemo, useRef } from "react";
import {
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Button,
Box,
Typography,
Paper,
FormControlLabel,
Checkbox,
TextField,
FormControl,
InputLabel,
Select,
MenuItem,
CircularProgress,
Chip
} from "@mui/material";
import { Folder as FolderIcon, Description as DescriptionIcon } from "@mui/icons-material";
import { SimpleTreeView, TreeItem } from "@mui/x-tree-view";
import { DomainBadge } from "../Assemblies/Assembly_Badges";
import {
buildAssemblyIndex,
buildAssemblyTree,
normalizeAssemblyPath,
parseAssemblyExport
} from "../Assemblies/assemblyUtils";
const DIALOG_SHELL_SX = {
backgroundImage: "linear-gradient(120deg,#040711 0%,#0b1222 55%,#020617 100%)",
border: "1px solid rgba(148,163,184,0.35)",
boxShadow: "0 28px 60px rgba(2,6,12,0.65)",
borderRadius: 3,
color: "#e2e8f0",
overflow: "hidden"
};
const GLASS_PANEL_SX = {
backgroundColor: "rgba(15,23,42,0.78)",
border: "1px solid rgba(148,163,184,0.35)",
borderRadius: 3,
boxShadow: "0 16px 40px rgba(2,6,15,0.45)",
backdropFilter: "blur(22px)"
};
const PRIMARY_PILL_GRADIENT = "linear-gradient(135deg,#34d399,#22d3ee)";
const SECONDARY_PILL_GRADIENT = "linear-gradient(135deg,#7dd3fc,#c084fc)";
export default function QuickJob({ open, onClose, hostnames = [] }) {
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);
const [mode, setMode] = useState("scripts"); // 'scripts' | 'ansible'
const [credentials, setCredentials] = useState([]);
const [credentialsLoading, setCredentialsLoading] = useState(false);
const [credentialsError, setCredentialsError] = useState("");
const [selectedCredentialId, setSelectedCredentialId] = useState("");
const [useSvcAccount, setUseSvcAccount] = useState(true);
const [variables, setVariables] = useState([]);
const [variableValues, setVariableValues] = useState({});
const [variableErrors, setVariableErrors] = useState({});
const [variableStatus, setVariableStatus] = useState({ loading: false, error: "" });
const assemblyExportCacheRef = useRef(new Map());
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();
setAssemblyPayload({
items: Array.isArray(data?.items) ? data.items : [],
queue: Array.isArray(data?.queue) ? data.queue : []
});
} catch (err) {
console.error("Failed to load assemblies:", err);
setAssemblyPayload({ items: [], queue: [] });
setAssembliesError(err?.message || "Failed to load assemblies");
} finally {
setAssembliesLoading(false);
}
}, []);
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) {
setSelectedAssemblyGuid("");
return;
}
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;
let canceled = false;
setCredentialsLoading(true);
setCredentialsError("");
(async () => {
try {
const resp = await fetch("/api/credentials");
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const data = await resp.json();
if (canceled) return;
const list = Array.isArray(data?.credentials)
? data.credentials.filter((cred) => {
const conn = String(cred.connection_type || "").toLowerCase();
return conn === "ssh" || conn === "winrm";
})
: [];
list.sort((a, b) => String(a?.name || "").localeCompare(String(b?.name || "")));
setCredentials(list);
} catch (err) {
if (!canceled) {
setCredentials([]);
setCredentialsError(String(err.message || err));
}
} finally {
if (!canceled) setCredentialsLoading(false);
}
})();
return () => {
canceled = true;
};
}, [open, mode]);
useEffect(() => {
if (!open) {
setSelectedCredentialId("");
}
}, [open]);
useEffect(() => {
if (mode !== "ansible" || useSvcAccount) return;
if (!credentials.length) {
setSelectedCredentialId("");
return;
}
if (!selectedCredentialId || !credentials.some((cred) => String(cred.id) === String(selectedCredentialId))) {
setSelectedCredentialId(String(credentials[0].id));
}
}, [mode, credentials, selectedCredentialId, useSvcAccount]);
const renderNodes = (nodes = []) =>
nodes.map((n) => (
{n.isFolder ? (
) : (
)}
{n.label}
}
>
{n.children && n.children.length ? renderNodes(n.children) : null}
));
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;
if (type === "boolean") {
if (typeof defaultValue === "boolean") return defaultValue;
if (defaultValue == null) return false;
const str = String(defaultValue).trim().toLowerCase();
if (!str) return false;
return ["true", "1", "yes", "on"].includes(str);
}
if (type === "number") {
if (defaultValue == null || defaultValue === "") return "";
if (typeof defaultValue === "number" && Number.isFinite(defaultValue)) {
return String(defaultValue);
}
const parsed = Number(defaultValue);
return Number.isFinite(parsed) ? String(parsed) : "";
}
return defaultValue == null ? "" : String(defaultValue);
};
useEffect(() => {
if (!selectedAssemblyGuid) {
setVariables([]);
setVariableValues({});
setVariableErrors({});
setVariableStatus({ loading: false, error: "" });
return;
}
let canceled = false;
(async () => {
setVariableStatus({ loading: true, error: "" });
try {
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) return;
setVariables([]);
setVariableValues({});
setVariableErrors({});
setVariableStatus({ loading: false, error: err?.message || String(err) });
}
})();
return () => {
canceled = true;
};
}, [selectedAssemblyGuid, loadAssemblyExport]);
const handleVariableChange = (variable, rawValue) => {
const { name, type } = variable;
if (!name) return;
setVariableValues((prev) => ({
...prev,
[name]: type === "boolean" ? Boolean(rawValue) : rawValue
}));
setVariableErrors((prev) => {
if (!prev[name]) return prev;
const next = { ...prev };
delete next[name];
return next;
});
};
const buildVariablePayload = () => {
const payload = {};
variables.forEach((variable) => {
if (!variable?.name) return;
const { name, type } = variable;
const hasOverride = Object.prototype.hasOwnProperty.call(variableValues, name);
const raw = hasOverride ? variableValues[name] : deriveInitialValue(variable);
if (type === "boolean") {
payload[name] = Boolean(raw);
} else if (type === "number") {
if (raw === "" || raw === null || raw === undefined) {
payload[name] = "";
} else {
const num = Number(raw);
payload[name] = Number.isFinite(num) ? num : "";
}
} else {
payload[name] = raw == null ? "" : String(raw);
}
});
return payload;
};
const onRun = async () => {
if (!selectedAssembly) {
setError(mode === "ansible" ? "Please choose a playbook to run." : "Please choose a script to run.");
return;
}
if (mode === "ansible" && !useSvcAccount && !selectedCredentialId) {
setError("Select a credential to run this playbook.");
return;
}
if (variables.length) {
const errors = {};
variables.forEach((variable) => {
if (!variable) return;
if (!variable.required) return;
if (variable.type === "boolean") return;
const hasOverride = Object.prototype.hasOwnProperty.call(variableValues, variable.name);
const raw = hasOverride ? variableValues[variable.name] : deriveInitialValue(variable);
if (raw == null || raw === "") {
errors[variable.name] = "Required";
}
});
if (Object.keys(errors).length) {
setVariableErrors(errors);
setError("Please fill in all required variable values.");
return;
}
}
setRunning(true);
setError("");
try {
let resp;
const variableOverrides = buildVariablePayload();
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: normalizedPath,
hostnames,
variable_values: variableOverrides,
credential_id: !useSvcAccount && selectedCredentialId ? Number(selectedCredentialId) : null,
use_service_account: Boolean(useSvcAccount)
})
});
} else {
resp = await fetch("/api/scripts/quick_run", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
script_path: normalizedPath,
hostnames,
run_mode: runAsCurrentUser ? "current_user" : "system",
variable_values: variableOverrides
})
});
}
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));
} finally {
setRunning(false);
}
};
const credentialRequired = mode === "ansible" && !useSvcAccount;
const disableRun =
running ||
!selectedAssembly ||
(credentialRequired && (!selectedCredentialId || !credentials.length));
const activeTreeData = mode === "ansible" ? ansibleTreeData : scriptTreeData;
const treeItems = Array.isArray(activeTreeData.root) ? activeTreeData.root : [];
const targetCount = hostnames.length;
const hostPreview = hostnames.slice(0, 3).join(", ");
const remainingHosts = Math.max(targetCount - 3, 0);
return (
);
}