mirror of
https://github.com/bunny-lab-io/Borealis.git
synced 2025-10-26 17:21:58 -06:00
Expose assembly variables for jobs
This commit is contained in:
@@ -124,7 +124,126 @@ function buildWorkflowTree(workflows, folders) {
|
||||
return { root: [rootNode], map };
|
||||
}
|
||||
|
||||
function ComponentCard({ comp, onRemove }) {
|
||||
function normalizeVariableDefinitions(vars = []) {
|
||||
return (Array.isArray(vars) ? vars : [])
|
||||
.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 label = typeof raw.label === "string" && raw.label.trim() ? raw.label.trim() : name;
|
||||
const type = typeof raw.type === "string" ? raw.type.toLowerCase() : "string";
|
||||
const required = Boolean(raw.required);
|
||||
const description = typeof raw.description === "string" ? raw.description : "";
|
||||
let defaultValue = "";
|
||||
if (Object.prototype.hasOwnProperty.call(raw, "default")) defaultValue = raw.default;
|
||||
else if (Object.prototype.hasOwnProperty.call(raw, "defaultValue")) defaultValue = raw.defaultValue;
|
||||
else if (Object.prototype.hasOwnProperty.call(raw, "default_value")) defaultValue = raw.default_value;
|
||||
return { name, label, type, required, description, default: defaultValue };
|
||||
})
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function coerceVariableValue(type, value) {
|
||||
if (type === "boolean") {
|
||||
if (typeof value === "boolean") return value;
|
||||
if (typeof value === "number") return value !== 0;
|
||||
if (value == null) return false;
|
||||
const str = String(value).trim().toLowerCase();
|
||||
if (!str) return false;
|
||||
return ["true", "1", "yes", "on"].includes(str);
|
||||
}
|
||||
if (type === "number") {
|
||||
if (value == null || value === "") return "";
|
||||
if (typeof value === "number" && Number.isFinite(value)) return String(value);
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? String(parsed) : "";
|
||||
}
|
||||
return value == null ? "" : String(value);
|
||||
}
|
||||
|
||||
function mergeComponentVariables(docVars = [], storedVars = [], storedValueMap = {}) {
|
||||
const definitions = normalizeVariableDefinitions(docVars);
|
||||
const overrides = {};
|
||||
const storedMeta = {};
|
||||
(Array.isArray(storedVars) ? storedVars : []).forEach((raw) => {
|
||||
if (!raw || typeof raw !== "object") return;
|
||||
const name = typeof raw.name === "string" ? raw.name.trim() : "";
|
||||
if (!name) return;
|
||||
if (Object.prototype.hasOwnProperty.call(raw, "value")) overrides[name] = raw.value;
|
||||
else if (Object.prototype.hasOwnProperty.call(raw, "default")) overrides[name] = raw.default;
|
||||
storedMeta[name] = {
|
||||
label: typeof raw.label === "string" && raw.label.trim() ? raw.label.trim() : name,
|
||||
type: typeof raw.type === "string" ? raw.type.toLowerCase() : undefined,
|
||||
required: Boolean(raw.required),
|
||||
description: typeof raw.description === "string" ? raw.description : "",
|
||||
default: Object.prototype.hasOwnProperty.call(raw, "default") ? raw.default : ""
|
||||
};
|
||||
});
|
||||
if (storedValueMap && typeof storedValueMap === "object") {
|
||||
Object.entries(storedValueMap).forEach(([key, val]) => {
|
||||
const name = typeof key === "string" ? key.trim() : "";
|
||||
if (name) overrides[name] = val;
|
||||
});
|
||||
}
|
||||
|
||||
const used = new Set();
|
||||
const merged = definitions.map((def) => {
|
||||
const override = Object.prototype.hasOwnProperty.call(overrides, def.name) ? overrides[def.name] : undefined;
|
||||
used.add(def.name);
|
||||
return {
|
||||
...def,
|
||||
value: override !== undefined ? coerceVariableValue(def.type, override) : coerceVariableValue(def.type, def.default)
|
||||
};
|
||||
});
|
||||
|
||||
(Array.isArray(storedVars) ? storedVars : []).forEach((raw) => {
|
||||
if (!raw || typeof raw !== "object") return;
|
||||
const name = typeof raw.name === "string" ? raw.name.trim() : "";
|
||||
if (!name || used.has(name)) return;
|
||||
const meta = storedMeta[name] || {};
|
||||
const type = meta.type || (typeof overrides[name] === "boolean" ? "boolean" : typeof overrides[name] === "number" ? "number" : "string");
|
||||
const defaultValue = Object.prototype.hasOwnProperty.call(meta, "default") ? meta.default : "";
|
||||
const override = Object.prototype.hasOwnProperty.call(overrides, name)
|
||||
? overrides[name]
|
||||
: Object.prototype.hasOwnProperty.call(raw, "value")
|
||||
? raw.value
|
||||
: defaultValue;
|
||||
merged.push({
|
||||
name,
|
||||
label: meta.label || name,
|
||||
type,
|
||||
required: Boolean(meta.required),
|
||||
description: meta.description || "",
|
||||
default: defaultValue,
|
||||
value: coerceVariableValue(type, override)
|
||||
});
|
||||
used.add(name);
|
||||
});
|
||||
|
||||
Object.entries(overrides).forEach(([nameRaw, val]) => {
|
||||
const name = typeof nameRaw === "string" ? nameRaw.trim() : "";
|
||||
if (!name || used.has(name)) return;
|
||||
const type = typeof val === "boolean" ? "boolean" : typeof val === "number" ? "number" : "string";
|
||||
merged.push({
|
||||
name,
|
||||
label: name,
|
||||
type,
|
||||
required: false,
|
||||
description: "",
|
||||
default: "",
|
||||
value: coerceVariableValue(type, val)
|
||||
});
|
||||
used.add(name);
|
||||
});
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
function ComponentCard({ comp, onRemove, onVariableChange, errors = {} }) {
|
||||
const variables = Array.isArray(comp.variables)
|
||||
? comp.variables.filter((v) => v && typeof v.name === "string" && v.name)
|
||||
: [];
|
||||
const description = comp.description || comp.path || "";
|
||||
return (
|
||||
<Paper sx={{ bgcolor: "#2a2a2a", border: "1px solid #3a3a3a", p: 1.2, mb: 1.2, borderRadius: 1 }}>
|
||||
<Box sx={{ display: "flex", gap: 2 }}>
|
||||
@@ -133,30 +252,65 @@ function ComponentCard({ comp, onRemove }) {
|
||||
{comp.type === "script" ? comp.name : comp.name}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: "#aaa" }}>
|
||||
{comp.description || (comp.type === "script" ? comp.path : comp.path)}
|
||||
{description}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Divider orientation="vertical" flexItem sx={{ borderColor: "#333" }} />
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Typography variant="subtitle2" sx={{ color: "#ccc", mb: 1 }}>Variables (placeholder)</Typography>
|
||||
<FormControlLabel control={<Checkbox size="small" />} label={<Typography variant="body2">Example toggle</Typography>} />
|
||||
<Box sx={{ display: "flex", gap: 1, mt: 1 }}>
|
||||
<TextField size="small" label="Param A" variant="outlined" fullWidth
|
||||
InputLabelProps={{ shrink: true }}
|
||||
sx={{
|
||||
"& .MuiOutlinedInput-root": { bgcolor: "#1b1b1b" },
|
||||
"& .MuiInputLabel-root": { color: "#aaa" },
|
||||
"& .MuiInputBase-input": { color: "#e6edf3" }
|
||||
}}
|
||||
/>
|
||||
<Select size="small" value={"install"} sx={{ minWidth: 160 }}>
|
||||
<MenuItem value="install">Install/Update existing installation</MenuItem>
|
||||
<MenuItem value="uninstall">Uninstall</MenuItem>
|
||||
</Select>
|
||||
</Box>
|
||||
<Typography variant="subtitle2" sx={{ color: "#ccc", mb: 1 }}>Variables</Typography>
|
||||
{variables.length ? (
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 1.5 }}>
|
||||
{variables.map((variable) => (
|
||||
<Box key={variable.name}>
|
||||
{variable.type === "boolean" ? (
|
||||
<>
|
||||
<FormControlLabel
|
||||
control={(
|
||||
<Checkbox
|
||||
size="small"
|
||||
checked={Boolean(variable.value)}
|
||||
onChange={(e) => onVariableChange(comp.localId, variable.name, e.target.checked)}
|
||||
/>
|
||||
)}
|
||||
label={
|
||||
<Typography variant="body2">
|
||||
{variable.label}
|
||||
{variable.required ? " *" : ""}
|
||||
</Typography>
|
||||
}
|
||||
/>
|
||||
{variable.description ? (
|
||||
<Typography variant="caption" sx={{ color: "#888", ml: 3 }}>
|
||||
{variable.description}
|
||||
</Typography>
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
<TextField
|
||||
fullWidth
|
||||
size="small"
|
||||
label={`${variable.label}${variable.required ? " *" : ""}`}
|
||||
type={variable.type === "number" ? "number" : variable.type === "credential" ? "password" : "text"}
|
||||
value={variable.value ?? ""}
|
||||
onChange={(e) => onVariableChange(comp.localId, variable.name, e.target.value)}
|
||||
InputLabelProps={{ shrink: true }}
|
||||
sx={{
|
||||
"& .MuiOutlinedInput-root": { bgcolor: "#1b1b1b", color: "#e6edf3" },
|
||||
"& .MuiInputBase-input": { color: "#e6edf3" }
|
||||
}}
|
||||
error={Boolean(errors[variable.name])}
|
||||
helperText={errors[variable.name] || variable.description || ""}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
) : (
|
||||
<Typography variant="body2" sx={{ color: "#888" }}>No variables defined for this assembly.</Typography>
|
||||
)}
|
||||
</Box>
|
||||
<Box>
|
||||
<IconButton onClick={onRemove} size="small" sx={{ color: "#ff6666" }}>
|
||||
<IconButton onClick={() => onRemove(comp.localId)} size="small" sx={{ color: "#ff6666" }}>
|
||||
<DeleteIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Box>
|
||||
@@ -189,6 +343,134 @@ export default function CreateJob({ onCancel, onCreated, initialJob = null }) {
|
||||
const [availableDevices, setAvailableDevices] = useState([]); // [{hostname, display, online}]
|
||||
const [selectedTargets, setSelectedTargets] = useState({}); // map hostname->bool
|
||||
const [deviceSearch, setDeviceSearch] = useState("");
|
||||
const [componentVarErrors, setComponentVarErrors] = useState({});
|
||||
|
||||
const generateLocalId = useCallback(
|
||||
() => `${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
|
||||
[]
|
||||
);
|
||||
|
||||
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: "" };
|
||||
try {
|
||||
const island = type === "ansible" ? "ansible" : "scripts";
|
||||
const resp = await fetch(`/api/assembly/load?island=${island}&path=${encodeURIComponent(normalizedPath)}`);
|
||||
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) {
|
||||
if (!raw || typeof raw !== "object") continue;
|
||||
const typeRaw = raw.type || raw.component_type || "script";
|
||||
if (typeRaw === "workflow") {
|
||||
results.push({
|
||||
...raw,
|
||||
type: "workflow",
|
||||
variables: Array.isArray(raw.variables) ? raw.variables : [],
|
||||
localId: generateLocalId()
|
||||
});
|
||||
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 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,
|
||||
variables: mergedVariables,
|
||||
localId: generateLocalId()
|
||||
});
|
||||
}
|
||||
return results;
|
||||
}, [fetchAssemblyDoc, generateLocalId]);
|
||||
|
||||
const sanitizeComponentsForSave = useCallback((items) => {
|
||||
return (Array.isArray(items) ? items : []).map((comp) => {
|
||||
if (!comp || typeof comp !== "object") return comp;
|
||||
const { localId, ...rest } = comp;
|
||||
const sanitized = { ...rest };
|
||||
if (Array.isArray(comp.variables)) {
|
||||
const valuesMap = {};
|
||||
sanitized.variables = comp.variables
|
||||
.filter((v) => v && typeof v.name === "string" && v.name)
|
||||
.map((v) => {
|
||||
const entry = {
|
||||
name: v.name,
|
||||
label: v.label || v.name,
|
||||
type: v.type || "string",
|
||||
required: Boolean(v.required),
|
||||
description: v.description || ""
|
||||
};
|
||||
if (Object.prototype.hasOwnProperty.call(v, "default")) entry.default = v.default;
|
||||
if (Object.prototype.hasOwnProperty.call(v, "value")) {
|
||||
entry.value = v.value;
|
||||
valuesMap[v.name] = v.value;
|
||||
}
|
||||
return entry;
|
||||
});
|
||||
if (!sanitized.variables.length) sanitized.variables = [];
|
||||
if (Object.keys(valuesMap).length) sanitized.variable_values = valuesMap;
|
||||
else delete sanitized.variable_values;
|
||||
}
|
||||
return sanitized;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const updateComponentVariable = useCallback((localId, name, value) => {
|
||||
if (!localId || !name) return;
|
||||
setComponents((prev) => prev.map((comp) => {
|
||||
if (!comp || comp.localId !== localId) return comp;
|
||||
const vars = Array.isArray(comp.variables) ? comp.variables : [];
|
||||
const nextVars = vars.map((variable) => {
|
||||
if (!variable || variable.name !== name) return variable;
|
||||
return { ...variable, value: coerceVariableValue(variable.type || "string", value) };
|
||||
});
|
||||
return { ...comp, variables: nextVars };
|
||||
}));
|
||||
setComponentVarErrors((prev) => {
|
||||
if (!prev[localId] || !prev[localId][name]) return prev;
|
||||
const next = { ...prev };
|
||||
const compErrors = { ...next[localId] };
|
||||
delete compErrors[name];
|
||||
if (Object.keys(compErrors).length) next[localId] = compErrors;
|
||||
else delete next[localId];
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const removeComponent = useCallback((localId) => {
|
||||
setComponents((prev) => prev.filter((comp) => comp.localId !== localId));
|
||||
setComponentVarErrors((prev) => {
|
||||
if (!prev[localId]) return prev;
|
||||
const next = { ...prev };
|
||||
delete next[localId];
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const isValid = useMemo(() => {
|
||||
const base = jobName.trim().length > 0 && components.length > 0 && targets.length > 0;
|
||||
@@ -355,18 +637,32 @@ export default function CreateJob({ onCancel, onCreated, initialJob = null }) {
|
||||
const deviceSorted = useMemo(() => deviceRows, [deviceRows]);
|
||||
|
||||
useEffect(() => {
|
||||
if (initialJob && initialJob.id) {
|
||||
setJobName(initialJob.name || "");
|
||||
const comps = Array.isArray(initialJob.components) ? initialJob.components : [];
|
||||
setComponents(comps.length ? comps : []);
|
||||
setTargets(Array.isArray(initialJob.targets) ? initialJob.targets : []);
|
||||
setScheduleType(initialJob.schedule_type || initialJob.schedule?.type || "immediately");
|
||||
setStartDateTime(initialJob.start_ts ? dayjs(Number(initialJob.start_ts) * 1000).second(0) : (initialJob.schedule?.start ? dayjs(initialJob.schedule.start).second(0) : dayjs().add(5, "minute").second(0)));
|
||||
setStopAfterEnabled(Boolean(initialJob.duration_stop_enabled));
|
||||
setExpiration(initialJob.expiration || "no_expire");
|
||||
setExecContext(initialJob.execution_context || "system");
|
||||
}
|
||||
}, [initialJob]);
|
||||
let canceled = false;
|
||||
const hydrate = async () => {
|
||||
if (initialJob && initialJob.id) {
|
||||
setJobName(initialJob.name || "");
|
||||
setTargets(Array.isArray(initialJob.targets) ? initialJob.targets : []);
|
||||
setScheduleType(initialJob.schedule_type || initialJob.schedule?.type || "immediately");
|
||||
setStartDateTime(initialJob.start_ts ? dayjs(Number(initialJob.start_ts) * 1000).second(0) : (initialJob.schedule?.start ? dayjs(initialJob.schedule.start).second(0) : dayjs().add(5, "minute").second(0)));
|
||||
setStopAfterEnabled(Boolean(initialJob.duration_stop_enabled));
|
||||
setExpiration(initialJob.expiration || "no_expire");
|
||||
setExecContext(initialJob.execution_context || "system");
|
||||
const comps = Array.isArray(initialJob.components) ? initialJob.components : [];
|
||||
const hydrated = await hydrateExistingComponents(comps);
|
||||
if (!canceled) {
|
||||
setComponents(hydrated);
|
||||
setComponentVarErrors({});
|
||||
}
|
||||
} else if (!initialJob) {
|
||||
setComponents([]);
|
||||
setComponentVarErrors({});
|
||||
}
|
||||
};
|
||||
hydrate();
|
||||
return () => {
|
||||
canceled = true;
|
||||
};
|
||||
}, [initialJob, hydrateExistingComponents]);
|
||||
|
||||
const openAddComponent = async () => {
|
||||
setAddCompOpen(true);
|
||||
@@ -399,32 +695,38 @@ export default function CreateJob({ onCancel, onCreated, initialJob = null }) {
|
||||
} catch { setAnsibleTree([]); setAnsibleMap({}); }
|
||||
};
|
||||
|
||||
const addSelectedComponent = () => {
|
||||
const addSelectedComponent = useCallback(async () => {
|
||||
const map = compTab === "scripts" ? scriptMap : (compTab === "ansible" ? ansibleMap : workflowMap);
|
||||
const node = map[selectedNodeId];
|
||||
if (!node || node.isFolder) return false;
|
||||
if (compTab === "scripts" && node.script) {
|
||||
setComponents((prev) => [
|
||||
...prev,
|
||||
// Store path relative to Assemblies root with 'Scripts/' prefix for scheduler compatibility
|
||||
{ type: "script", path: (node.path.startsWith('Scripts/') ? node.path : `Scripts/${node.path}`), name: node.fileName || node.label, description: node.path }
|
||||
]);
|
||||
setSelectedNodeId("");
|
||||
return true;
|
||||
} else if (compTab === "ansible" && node.script) {
|
||||
setComponents((prev) => [
|
||||
...prev,
|
||||
{ type: "ansible", path: node.path, name: node.fileName || node.label, description: node.path }
|
||||
]);
|
||||
setSelectedNodeId("");
|
||||
return true;
|
||||
} else if (compTab === "workflows" && node.workflow) {
|
||||
if (compTab === "workflows" && node.workflow) {
|
||||
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, [], {});
|
||||
setComponents((prev) => [
|
||||
...prev,
|
||||
{
|
||||
type,
|
||||
path: normalizedPath || rawPath,
|
||||
name: assembly?.name || node.fileName || node.label,
|
||||
description: assembly?.description || normalizedPath || rawPath,
|
||||
variables: mergedVars,
|
||||
localId: generateLocalId()
|
||||
}
|
||||
]);
|
||||
setSelectedNodeId("");
|
||||
return true;
|
||||
}
|
||||
setSelectedNodeId("");
|
||||
return false;
|
||||
};
|
||||
}, [compTab, scriptMap, ansibleMap, workflowMap, selectedNodeId, fetchAssemblyDoc, generateLocalId]);
|
||||
|
||||
const openAddTargets = async () => {
|
||||
setAddTargetOpen(true);
|
||||
@@ -449,9 +751,30 @@ export default function CreateJob({ onCancel, onCreated, initialJob = null }) {
|
||||
};
|
||||
|
||||
const handleCreate = async () => {
|
||||
const requiredErrors = {};
|
||||
components.forEach((comp) => {
|
||||
if (!comp || !comp.localId) return;
|
||||
(Array.isArray(comp.variables) ? comp.variables : []).forEach((variable) => {
|
||||
if (!variable || !variable.name || !variable.required) return;
|
||||
if ((variable.type || "string") === "boolean") return;
|
||||
const value = variable.value;
|
||||
if (value == null || value === "") {
|
||||
if (!requiredErrors[comp.localId]) requiredErrors[comp.localId] = {};
|
||||
requiredErrors[comp.localId][variable.name] = "Required";
|
||||
}
|
||||
});
|
||||
});
|
||||
if (Object.keys(requiredErrors).length) {
|
||||
setComponentVarErrors(requiredErrors);
|
||||
setTab(1);
|
||||
alert("Please fill in all required variable values.");
|
||||
return;
|
||||
}
|
||||
setComponentVarErrors({});
|
||||
const payloadComponents = sanitizeComponentsForSave(components);
|
||||
const payload = {
|
||||
name: jobName,
|
||||
components,
|
||||
components: payloadComponents,
|
||||
targets,
|
||||
schedule: { type: scheduleType, start: scheduleType !== "immediately" ? (() => { try { const d = startDateTime?.toDate?.() || new Date(startDateTime); d.setSeconds(0,0); return d.toISOString(); } catch { return startDateTime; } })() : null },
|
||||
duration: { stopAfterEnabled, expiration },
|
||||
@@ -553,9 +876,13 @@ export default function CreateJob({ onCancel, onCreated, initialJob = null }) {
|
||||
{components.length === 0 && (
|
||||
<Typography variant="body2" sx={{ color: "#888" }}>No assemblies added yet.</Typography>
|
||||
)}
|
||||
{components.map((c, idx) => (
|
||||
<ComponentCard key={`${c.type}-${c.path}-${idx}`} comp={c}
|
||||
onRemove={() => setComponents((prev) => prev.filter((_, i) => i !== idx))}
|
||||
{components.map((c) => (
|
||||
<ComponentCard
|
||||
key={c.localId || `${c.type}-${c.path}`}
|
||||
comp={c}
|
||||
onRemove={removeComponent}
|
||||
onVariableChange={updateComponentVariable}
|
||||
errors={componentVarErrors[c.localId] || {}}
|
||||
/>
|
||||
))}
|
||||
{components.length === 0 && (
|
||||
@@ -820,7 +1147,7 @@ export default function CreateJob({ onCancel, onCreated, initialJob = null }) {
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setAddCompOpen(false)} sx={{ color: "#58a6ff" }}>Close</Button>
|
||||
<Button onClick={() => { const ok = addSelectedComponent(); if (ok) setAddCompOpen(false); }}
|
||||
<Button onClick={async () => { const ok = await addSelectedComponent(); if (ok) setAddCompOpen(false); }}
|
||||
sx={{ color: "#58a6ff" }} disabled={!selectedNodeId}
|
||||
>Add</Button>
|
||||
</DialogActions>
|
||||
|
||||
@@ -9,7 +9,8 @@ import {
|
||||
Typography,
|
||||
Paper,
|
||||
FormControlLabel,
|
||||
Checkbox
|
||||
Checkbox,
|
||||
TextField
|
||||
} from "@mui/material";
|
||||
import { Folder as FolderIcon, Description as DescriptionIcon } from "@mui/icons-material";
|
||||
import { SimpleTreeView, TreeItem } from "@mui/x-tree-view";
|
||||
@@ -81,6 +82,10 @@ export default function QuickJob({ open, onClose, hostnames = [] }) {
|
||||
const [error, setError] = useState("");
|
||||
const [runAsCurrentUser, setRunAsCurrentUser] = useState(false);
|
||||
const [mode, setMode] = useState("scripts"); // 'scripts' | 'ansible'
|
||||
const [variables, setVariables] = useState([]);
|
||||
const [variableValues, setVariableValues] = useState({});
|
||||
const [variableErrors, setVariableErrors] = useState({});
|
||||
const [variableStatus, setVariableStatus] = useState({ loading: false, error: "" });
|
||||
|
||||
const loadTree = useCallback(async () => {
|
||||
try {
|
||||
@@ -102,6 +107,10 @@ export default function QuickJob({ open, onClose, hostnames = [] }) {
|
||||
if (open) {
|
||||
setSelectedPath("");
|
||||
setError("");
|
||||
setVariables([]);
|
||||
setVariableValues({});
|
||||
setVariableErrors({});
|
||||
setVariableStatus({ loading: false, error: "" });
|
||||
loadTree();
|
||||
}
|
||||
}, [open, loadTree]);
|
||||
@@ -131,24 +140,170 @@ export default function QuickJob({ open, onClose, hostnames = [] }) {
|
||||
if (node && !node.isFolder) {
|
||||
setSelectedPath(node.path);
|
||||
setError("");
|
||||
setVariableErrors({});
|
||||
}
|
||||
};
|
||||
|
||||
const normalizeVariables = (list) => {
|
||||
if (!Array.isArray(list)) return [];
|
||||
return list
|
||||
.map((raw) => {
|
||||
if (!raw || typeof raw !== "object") return null;
|
||||
const name = typeof raw.name === "string" ? raw.name.trim() : typeof raw.key === "string" ? raw.key.trim() : "";
|
||||
if (!name) return null;
|
||||
const type = typeof raw.type === "string" ? raw.type.toLowerCase() : "string";
|
||||
const label = typeof raw.label === "string" && raw.label.trim() ? raw.label.trim() : name;
|
||||
const description = typeof raw.description === "string" ? raw.description : "";
|
||||
const required = Boolean(raw.required);
|
||||
const defaultValue = raw.hasOwnProperty("default")
|
||||
? raw.default
|
||||
: raw.hasOwnProperty("defaultValue")
|
||||
? raw.defaultValue
|
||||
: raw.hasOwnProperty("default_value")
|
||||
? raw.default_value
|
||||
: "";
|
||||
return { name, label, type, description, required, default: defaultValue };
|
||||
})
|
||||
.filter(Boolean);
|
||||
};
|
||||
|
||||
const 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 (!selectedPath) {
|
||||
setVariables([]);
|
||||
setVariableValues({});
|
||||
setVariableErrors({});
|
||||
setVariableStatus({ loading: false, error: "" });
|
||||
return;
|
||||
}
|
||||
let canceled = false;
|
||||
const loadAssembly = async () => {
|
||||
setVariableStatus({ loading: true, error: "" });
|
||||
try {
|
||||
const island = mode === "ansible" ? "ansible" : "scripts";
|
||||
const relPath = island === "scripts"
|
||||
? (selectedPath.startsWith("Scripts/") ? selectedPath : `Scripts/${selectedPath}`)
|
||||
: selectedPath;
|
||||
const resp = await fetch(`/api/assembly/load?island=${island}&path=${encodeURIComponent(relPath)}`);
|
||||
if (!resp.ok) throw new Error(`Failed to load assembly (HTTP ${resp.status})`);
|
||||
const data = await resp.json();
|
||||
const defs = normalizeVariables(data?.assembly?.variables || []);
|
||||
if (!canceled) {
|
||||
setVariables(defs);
|
||||
const initialValues = {};
|
||||
defs.forEach((v) => {
|
||||
initialValues[v.name] = deriveInitialValue(v);
|
||||
});
|
||||
setVariableValues(initialValues);
|
||||
setVariableErrors({});
|
||||
setVariableStatus({ loading: false, error: "" });
|
||||
}
|
||||
} catch (err) {
|
||||
if (!canceled) {
|
||||
setVariables([]);
|
||||
setVariableValues({});
|
||||
setVariableErrors({});
|
||||
setVariableStatus({ loading: false, error: err?.message || String(err) });
|
||||
}
|
||||
}
|
||||
};
|
||||
loadAssembly();
|
||||
return () => {
|
||||
canceled = true;
|
||||
};
|
||||
}, [selectedPath, mode]);
|
||||
|
||||
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 (!selectedPath) {
|
||||
setError(mode === 'ansible' ? "Please choose a playbook to run." : "Please choose a script to run.");
|
||||
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();
|
||||
if (mode === 'ansible') {
|
||||
const playbook_path = selectedPath; // relative to ansible island
|
||||
resp = await fetch("/api/ansible/quick_run", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ playbook_path, hostnames })
|
||||
body: JSON.stringify({ playbook_path, hostnames, variable_values: variableOverrides })
|
||||
});
|
||||
} else {
|
||||
// quick_run expects a path relative to Assemblies root with 'Scripts/' prefix
|
||||
@@ -156,7 +311,12 @@ export default function QuickJob({ open, onClose, hostnames = [] }) {
|
||||
resp = await fetch("/api/scripts/quick_run", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ script_path, hostnames, run_mode: runAsCurrentUser ? "current_user" : "system" })
|
||||
body: JSON.stringify({
|
||||
script_path,
|
||||
hostnames,
|
||||
run_mode: runAsCurrentUser ? "current_user" : "system",
|
||||
variable_values: variableOverrides
|
||||
})
|
||||
});
|
||||
}
|
||||
const data = await resp.json();
|
||||
@@ -210,6 +370,61 @@ export default function QuickJob({ open, onClose, hostnames = [] }) {
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
<Box sx={{ mt: 3 }}>
|
||||
<Typography variant="subtitle2" sx={{ color: "#ccc", mb: 1 }}>Variables</Typography>
|
||||
{variableStatus.loading ? (
|
||||
<Typography variant="body2" sx={{ color: "#888" }}>Loading variables…</Typography>
|
||||
) : variableStatus.error ? (
|
||||
<Typography variant="body2" sx={{ color: "#ff4f4f" }}>{variableStatus.error}</Typography>
|
||||
) : variables.length ? (
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 1.5 }}>
|
||||
{variables.map((variable) => (
|
||||
<Box key={variable.name}>
|
||||
{variable.type === "boolean" ? (
|
||||
<FormControlLabel
|
||||
control={(
|
||||
<Checkbox
|
||||
size="small"
|
||||
checked={Boolean(variableValues[variable.name])}
|
||||
onChange={(e) => handleVariableChange(variable, e.target.checked)}
|
||||
/>
|
||||
)}
|
||||
label={
|
||||
<Typography variant="body2">
|
||||
{variable.label}
|
||||
{variable.required ? " *" : ""}
|
||||
</Typography>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<TextField
|
||||
fullWidth
|
||||
size="small"
|
||||
label={`${variable.label}${variable.required ? " *" : ""}`}
|
||||
type={variable.type === "number" ? "number" : variable.type === "credential" ? "password" : "text"}
|
||||
value={variableValues[variable.name] ?? ""}
|
||||
onChange={(e) => 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 ? (
|
||||
<Typography variant="caption" sx={{ color: "#888", ml: 3 }}>
|
||||
{variable.description}
|
||||
</Typography>
|
||||
) : null}
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
) : (
|
||||
<Typography variant="body2" sx={{ color: "#888" }}>No variables defined for this assembly.</Typography>
|
||||
)}
|
||||
</Box>
|
||||
{error && (
|
||||
<Typography variant="body2" sx={{ color: "#ff4f4f", mt: 1 }}>{error}</Typography>
|
||||
)}
|
||||
|
||||
@@ -25,6 +25,14 @@ def _now_ts() -> int:
|
||||
return int(time.time())
|
||||
|
||||
|
||||
def _env_string(value: Any) -> str:
|
||||
if isinstance(value, bool):
|
||||
return "True" if value else "False"
|
||||
if value is None:
|
||||
return ""
|
||||
return str(value)
|
||||
|
||||
|
||||
def _parse_ts(val: Any) -> Optional[int]:
|
||||
"""Best effort to parse ISO-ish datetime string or numeric seconds to epoch seconds."""
|
||||
if val is None:
|
||||
@@ -336,14 +344,21 @@ class JobScheduler:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _dispatch_script(self, hostname: str, rel_path: str, run_mode: str) -> None:
|
||||
def _dispatch_script(self, hostname: str, component: Dict[str, Any], run_mode: str) -> None:
|
||||
"""Emit a quick_job_run event to agents for the given script/host.
|
||||
Mirrors /api/scripts/quick_run behavior for scheduled jobs.
|
||||
"""
|
||||
try:
|
||||
scripts_root = self._scripts_root()
|
||||
import os
|
||||
path_norm = (rel_path or "").replace("\\", "/")
|
||||
rel_path_raw = ""
|
||||
if isinstance(component, dict):
|
||||
rel_path_raw = str(component.get("path") or component.get("script_path") or "")
|
||||
else:
|
||||
rel_path_raw = str(component or "")
|
||||
path_norm = (rel_path_raw or "").replace("\\", "/").strip()
|
||||
if path_norm and not path_norm.startswith("Scripts/"):
|
||||
path_norm = f"Scripts/{path_norm}"
|
||||
abs_path = os.path.abspath(os.path.join(scripts_root, path_norm))
|
||||
if (not abs_path.startswith(scripts_root)) or (not self._is_valid_scripts_relpath(path_norm)) or (not os.path.isfile(abs_path)):
|
||||
return
|
||||
@@ -353,8 +368,28 @@ class JobScheduler:
|
||||
if stype != "powershell":
|
||||
return
|
||||
content = doc.get("script") or ""
|
||||
doc_variables = doc.get("variables") if isinstance(doc.get("variables"), list) else []
|
||||
|
||||
overrides: Dict[str, Any] = {}
|
||||
if isinstance(component, dict):
|
||||
if isinstance(component.get("variable_values"), dict):
|
||||
for key, val in component.get("variable_values").items():
|
||||
name = str(key or "").strip()
|
||||
if name:
|
||||
overrides[name] = val
|
||||
if isinstance(component.get("variables"), list):
|
||||
for var in component.get("variables"):
|
||||
if not isinstance(var, dict):
|
||||
continue
|
||||
name = str(var.get("name") or "").strip()
|
||||
if not name:
|
||||
continue
|
||||
if "value" in var:
|
||||
overrides[name] = var.get("value")
|
||||
|
||||
env_map: Dict[str, str] = {}
|
||||
for var in doc.get("variables") or []:
|
||||
doc_names: Dict[str, bool] = {}
|
||||
for var in doc_variables:
|
||||
if not isinstance(var, dict):
|
||||
continue
|
||||
name = str(var.get("name") or "").strip()
|
||||
@@ -362,13 +397,32 @@ class JobScheduler:
|
||||
continue
|
||||
env_key = re.sub(r"[^A-Za-z0-9_]", "_", name.upper())
|
||||
default_val = var.get("default")
|
||||
if isinstance(default_val, bool):
|
||||
env_val = "True" if default_val else "False"
|
||||
elif default_val is None:
|
||||
env_val = ""
|
||||
if default_val is None and "defaultValue" in var:
|
||||
default_val = var.get("defaultValue")
|
||||
if default_val is None and "default_value" in var:
|
||||
default_val = var.get("default_value")
|
||||
env_map[env_key] = _env_string(default_val)
|
||||
doc_names[name] = True
|
||||
for name, val in overrides.items():
|
||||
env_key = re.sub(r"[^A-Za-z0-9_]", "_", name.upper())
|
||||
env_map[env_key] = _env_string(val)
|
||||
|
||||
variables: List[Dict[str, Any]] = []
|
||||
for var in doc_variables:
|
||||
if not isinstance(var, dict):
|
||||
continue
|
||||
name = str(var.get("name") or "").strip()
|
||||
if not name:
|
||||
continue
|
||||
if name in overrides:
|
||||
new_var = dict(var)
|
||||
new_var["value"] = overrides[name]
|
||||
variables.append(new_var)
|
||||
else:
|
||||
env_val = str(default_val)
|
||||
env_map[env_key] = env_val
|
||||
variables.append(var)
|
||||
for name, val in overrides.items():
|
||||
if name not in doc_names:
|
||||
variables.append({"name": name, "value": val})
|
||||
timeout_seconds = 0
|
||||
try:
|
||||
timeout_seconds = max(0, int(doc.get("timeout_seconds") or 0))
|
||||
@@ -411,7 +465,7 @@ class JobScheduler:
|
||||
"script_path": path_norm,
|
||||
"script_content": content,
|
||||
"environment": env_map,
|
||||
"variables": doc.get("variables") or [],
|
||||
"variables": variables,
|
||||
"timeout_seconds": timeout_seconds,
|
||||
"files": doc.get("files") or [],
|
||||
"run_mode": (run_mode or "system").strip().lower(),
|
||||
@@ -653,7 +707,7 @@ class JobScheduler:
|
||||
comps = json.loads(components_json or "[]")
|
||||
except Exception:
|
||||
comps = []
|
||||
script_paths = []
|
||||
script_components = []
|
||||
ansible_paths = []
|
||||
for c in comps:
|
||||
try:
|
||||
@@ -661,7 +715,9 @@ class JobScheduler:
|
||||
if ctype == "script":
|
||||
p = (c.get("path") or c.get("script_path") or "").strip()
|
||||
if p:
|
||||
script_paths.append(p)
|
||||
comp_copy = dict(c)
|
||||
comp_copy["path"] = p
|
||||
script_components.append(comp_copy)
|
||||
elif ctype == "ansible":
|
||||
p = (c.get("path") or "").strip()
|
||||
if p:
|
||||
@@ -755,9 +811,9 @@ class JobScheduler:
|
||||
run_row_id = c2.lastrowid or 0
|
||||
conn2.commit()
|
||||
# Dispatch all script components for this job to the target host
|
||||
for sp in script_paths:
|
||||
for comp in script_components:
|
||||
try:
|
||||
self._dispatch_script(host, sp, run_mode)
|
||||
self._dispatch_script(host, comp, run_mode)
|
||||
except Exception:
|
||||
continue
|
||||
# Dispatch ansible playbooks for this job to the target host
|
||||
|
||||
@@ -2843,9 +2843,18 @@ def scripts_quick_run():
|
||||
return jsonify({"error": f"Unsupported script type '{script_type}'. Only powershell is supported for Quick Job currently."}), 400
|
||||
|
||||
content = doc.get("script") or ""
|
||||
variables = doc.get("variables") if isinstance(doc.get("variables"), list) else []
|
||||
doc_variables = doc.get("variables") if isinstance(doc.get("variables"), list) else []
|
||||
|
||||
def _env_string(value: Any) -> str:
|
||||
if isinstance(value, bool):
|
||||
return "True" if value else "False"
|
||||
if value is None:
|
||||
return ""
|
||||
return str(value)
|
||||
|
||||
env_map: Dict[str, str] = {}
|
||||
for var in variables:
|
||||
doc_names: Dict[str, bool] = {}
|
||||
for var in doc_variables:
|
||||
if not isinstance(var, dict):
|
||||
continue
|
||||
name = str(var.get("name") or "").strip()
|
||||
@@ -2853,13 +2862,40 @@ def scripts_quick_run():
|
||||
continue
|
||||
env_key = re.sub(r"[^A-Za-z0-9_]", "_", name.upper())
|
||||
default_val = var.get("default")
|
||||
if isinstance(default_val, bool):
|
||||
env_val = "True" if default_val else "False"
|
||||
elif default_val is None:
|
||||
env_val = ""
|
||||
if default_val is None and "defaultValue" in var:
|
||||
default_val = var.get("defaultValue")
|
||||
if default_val is None and "default_value" in var:
|
||||
default_val = var.get("default_value")
|
||||
env_map[env_key] = _env_string(default_val)
|
||||
doc_names[name] = True
|
||||
|
||||
overrides_raw = data.get("variable_values")
|
||||
overrides: Dict[str, Any] = {}
|
||||
if isinstance(overrides_raw, dict):
|
||||
for key, val in overrides_raw.items():
|
||||
name = str(key or "").strip()
|
||||
if not name:
|
||||
continue
|
||||
overrides[name] = val
|
||||
env_key = re.sub(r"[^A-Za-z0-9_]", "_", name.upper())
|
||||
env_map[env_key] = _env_string(val)
|
||||
|
||||
variables: List[Dict[str, Any]] = []
|
||||
for var in doc_variables:
|
||||
if not isinstance(var, dict):
|
||||
continue
|
||||
name = str(var.get("name") or "").strip()
|
||||
if not name:
|
||||
continue
|
||||
if name in overrides:
|
||||
new_var = dict(var)
|
||||
new_var["value"] = overrides[name]
|
||||
variables.append(new_var)
|
||||
else:
|
||||
env_val = str(default_val)
|
||||
env_map[env_key] = env_val
|
||||
variables.append(var)
|
||||
for name, val in overrides.items():
|
||||
if name not in doc_names:
|
||||
variables.append({"name": name, "value": val})
|
||||
timeout_seconds = 0
|
||||
try:
|
||||
timeout_seconds = max(0, int(doc.get("timeout_seconds") or 0))
|
||||
|
||||
Reference in New Issue
Block a user