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 ( Quick Job Dispatch {mode === "ansible" ? "playbooks" : "scripts"} through the runner. Targets {hostnames.length || "—"} Mode {mode === "ansible" ? "Ansible" : "Script"} Select a {mode === 'ansible' ? 'playbook' : 'script'} to run on {hostnames.length} device{hostnames.length !== 1 ? "s" : ""}. {assembliesError ? ( {assembliesError} ) : null} {mode === 'ansible' && ( { const checked = e.target.checked; setUseSvcAccount(checked); if (checked) { setSelectedCredentialId(""); } else if (!selectedCredentialId && credentials.length) { setSelectedCredentialId(String(credentials[0].id)); } }} size="small" /> } label="Use Configured svcBorealis Account" sx={{ mr: 2 }} /> Credential {useSvcAccount && ( Runs with the agent's svcBorealis account. )} {credentialsLoading && } {!credentialsLoading && credentialsError && ( {credentialsError} )} {!useSvcAccount && !credentialsLoading && !credentialsError && !credentials.length && ( No SSH or WinRM credentials available. Create one under Access Management. )} )} {assembliesLoading ? ( Loading assemblies… ) : treeItems.length ? ( renderNodes(treeItems) ) : ( {mode === 'ansible' ? 'No playbooks found.' : 'No scripts found.'} )} Selection {selectedAssembly ? ( {selectedAssembly.displayName} {selectedAssembly.path} ) : ( {mode === 'ansible' ? 'No playbook selected' : 'No script selected'} )} {mode !== 'ansible' && ( <> setRunAsCurrentUser(e.target.checked)} />} label={Run as currently logged-in user} /> Unchecked = Run-As BUILTIN\SYSTEM )} Variables {variableStatus.loading ? ( Loading variables… ) : variableStatus.error ? ( {variableStatus.error} ) : variables.length ? ( {variables.map((variable) => ( {variable.type === "boolean" ? ( handleVariableChange(variable, e.target.checked)} /> )} label={ {variable.label} {variable.required ? " *" : ""} } /> ) : ( handleVariableChange(variable, e.target.value)} InputLabelProps={{ shrink: true }} sx={{ "& .MuiOutlinedInput-root": { bgcolor: "#1b1b1b", color: "#e6edf3" }, "& .MuiInputBase-input": { color: "#e6edf3" } }} error={Boolean(variableErrors[variable.name])} helperText={variableErrors[variable.name] || variable.description || ""} /> )} {variable.type === "boolean" && variable.description ? ( {variable.description} ) : null} ))} ) : ( No variables defined for this assembly. )} {error && ( {error} )} ); }