mirror of
https://github.com/bunny-lab-io/Borealis.git
synced 2025-10-27 05:01:57 -06:00
Scaffolding Added for Ansible Playbook Execution on Agents
This commit is contained in:
@@ -133,8 +133,12 @@ export default function DeviceDetails({ device, onBack }) {
|
||||
}
|
||||
}, [device]);
|
||||
|
||||
|
||||
|
||||
useEffect(() => { loadHistory(); }, [loadHistory]);
|
||||
|
||||
// No explicit live recap tab; recaps are recorded into Activity History
|
||||
|
||||
const clearHistory = async () => {
|
||||
if (!device?.hostname) return;
|
||||
try {
|
||||
@@ -771,13 +775,10 @@ export default function DeviceDetails({ device, onBack }) {
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Assembly</TableCell>
|
||||
<TableCell sortDirection={historyOrderBy === "script_name" ? historyOrder : false}>
|
||||
<TableSortLabel
|
||||
active={historyOrderBy === "script_name"}
|
||||
direction={historyOrderBy === "script_name" ? historyOrder : "asc"}
|
||||
onClick={() => handleHistorySort("script_name")}
|
||||
>
|
||||
Script Executed
|
||||
<TableSortLabel active={historyOrderBy === "script_name"} direction={historyOrderBy === "script_name" ? historyOrder : "asc"} onClick={() => handleHistorySort("script_name")}>
|
||||
Task
|
||||
</TableSortLabel>
|
||||
</TableCell>
|
||||
<TableCell sortDirection={historyOrderBy === "ran_at" ? historyOrder : false}>
|
||||
@@ -806,6 +807,7 @@ export default function DeviceDetails({ device, onBack }) {
|
||||
<TableBody>
|
||||
{sortedHistory.map((r) => (
|
||||
<TableRow key={r.id}>
|
||||
<TableCell>{(r.script_type || '').toLowerCase() === 'ansible' ? 'Ansible Playbook' : 'Script'}</TableCell>
|
||||
<TableCell>{r.script_name}</TableCell>
|
||||
<TableCell>{formatTimestamp(r.ran_at)}</TableCell>
|
||||
<TableCell>
|
||||
@@ -839,15 +841,15 @@ export default function DeviceDetails({ device, onBack }) {
|
||||
</TableRow>
|
||||
))}
|
||||
{sortedHistory.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={4} sx={{ color: "#888" }}>No activity yet.</TableCell>
|
||||
</TableRow>
|
||||
<TableRow><TableCell colSpan={5} sx={{ color: "#888" }}>No activity yet.</TableCell></TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Box>
|
||||
);
|
||||
|
||||
|
||||
|
||||
const tabs = [
|
||||
{ label: "Summary", content: renderSummary() },
|
||||
{ label: "Installed Software", content: renderSoftware() },
|
||||
@@ -959,6 +961,8 @@ export default function DeviceDetails({ device, onBack }) {
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
{/* Recap dialog removed; recaps flow into Activity History stdout */}
|
||||
|
||||
<ClearDeviceActivityDialog
|
||||
open={clearDialogOpen}
|
||||
onCancel={() => setClearDialogOpen(false)}
|
||||
|
||||
@@ -87,6 +87,11 @@ function buildScriptTree(scripts, folders) {
|
||||
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 = {};
|
||||
@@ -177,6 +182,7 @@ export default function CreateJob({ onCancel, onCreated, initialJob = null }) {
|
||||
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);
|
||||
@@ -382,10 +388,19 @@ export default function CreateJob({ onCancel, onCreated, initialJob = null }) {
|
||||
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({}); }
|
||||
};
|
||||
|
||||
const addSelectedComponent = () => {
|
||||
const map = compTab === "scripts" ? scriptMap : workflowMap;
|
||||
const map = compTab === "scripts" ? scriptMap : (compTab === "ansible" ? ansibleMap : workflowMap);
|
||||
const node = map[selectedNodeId];
|
||||
if (!node || node.isFolder) return false;
|
||||
if (compTab === "scripts" && node.script) {
|
||||
@@ -396,6 +411,13 @@ export default function CreateJob({ onCancel, onCreated, initialJob = null }) {
|
||||
]);
|
||||
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) {
|
||||
alert("Workflows within Scheduled Jobs are not supported yet");
|
||||
return false;
|
||||
@@ -453,7 +475,7 @@ export default function CreateJob({ onCancel, onCreated, initialJob = null }) {
|
||||
const tabDefs = useMemo(() => {
|
||||
const base = [
|
||||
{ key: "name", label: "Job Name" },
|
||||
{ key: "components", label: "Scripts/Workflows" },
|
||||
{ key: "components", label: "Assemblies" },
|
||||
{ key: "targets", label: "Targets" },
|
||||
{ key: "schedule", label: "Schedule" },
|
||||
{ key: "context", label: "Execution Context" }
|
||||
@@ -520,16 +542,16 @@ export default function CreateJob({ onCancel, onCreated, initialJob = null }) {
|
||||
{tab === 1 && (
|
||||
<Box>
|
||||
<SectionHeader
|
||||
title="Components"
|
||||
title="Assemblies"
|
||||
action={(
|
||||
<Button size="small" startIcon={<AddIcon />} onClick={openAddComponent}
|
||||
sx={{ color: "#58a6ff", borderColor: "#58a6ff" }} variant="outlined">
|
||||
Add Component
|
||||
Add Assembly
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
{components.length === 0 && (
|
||||
<Typography variant="body2" sx={{ color: "#888" }}>No components added yet.</Typography>
|
||||
<Typography variant="body2" sx={{ color: "#888" }}>No assemblies added yet.</Typography>
|
||||
)}
|
||||
{components.map((c, idx) => (
|
||||
<ComponentCard key={`${c.type}-${c.path}-${idx}`} comp={c}
|
||||
@@ -537,7 +559,7 @@ export default function CreateJob({ onCancel, onCreated, initialJob = null }) {
|
||||
/>
|
||||
))}
|
||||
{components.length === 0 && (
|
||||
<Typography variant="caption" sx={{ color: "#ff6666" }}>At least one component is required.</Typography>
|
||||
<Typography variant="caption" sx={{ color: "#ff6666" }}>At least one assembly is required.</Typography>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
@@ -731,13 +753,17 @@ export default function CreateJob({ onCancel, onCreated, initialJob = null }) {
|
||||
<Dialog open={addCompOpen} onClose={() => setAddCompOpen(false)} fullWidth maxWidth="md"
|
||||
PaperProps={{ sx: { bgcolor: "#121212", color: "#fff" } }}
|
||||
>
|
||||
<DialogTitle>Select a Script or Workflow</DialogTitle>
|
||||
<DialogTitle>Select an Assembly</DialogTitle>
|
||||
<DialogContent>
|
||||
<Box sx={{ display: "flex", gap: 2, mb: 1 }}>
|
||||
<Button size="small" variant={compTab === "scripts" ? "outlined" : "text"} onClick={() => setCompTab("scripts")}
|
||||
sx={{ textTransform: "none", color: "#58a6ff", borderColor: "#58a6ff" }}>
|
||||
Scripts
|
||||
</Button>
|
||||
<Button size="small" variant={compTab === "ansible" ? "outlined" : "text"} onClick={() => setCompTab("ansible")}
|
||||
sx={{ textTransform: "none", color: "#58a6ff", borderColor: "#58a6ff" }}>
|
||||
Ansible
|
||||
</Button>
|
||||
<Button size="small" variant={compTab === "workflows" ? "outlined" : "text"} onClick={() => setCompTab("workflows")}
|
||||
sx={{ textTransform: "none", color: "#58a6ff", borderColor: "#58a6ff" }}>
|
||||
Workflows
|
||||
@@ -775,6 +801,22 @@ export default function CreateJob({ onCancel, onCreated, initialJob = null }) {
|
||||
</SimpleTreeView>
|
||||
</Paper>
|
||||
)}
|
||||
{compTab === "ansible" && (
|
||||
<Paper sx={{ p: 1, bgcolor: "#1e1e1e", maxHeight: 400, overflow: "auto" }}>
|
||||
<SimpleTreeView onItemSelectionToggle={(_, id) => {
|
||||
const n = ansibleMap[id];
|
||||
if (n && !n.isFolder) setSelectedNodeId(id);
|
||||
}}>
|
||||
{ansibleTree.length ? (ansibleTree.map((n) => (
|
||||
<TreeItem key={n.id} itemId={n.id} label={n.label}>
|
||||
{n.children && n.children.length ? renderTreeNodes(n.children, ansibleMap) : null}
|
||||
</TreeItem>
|
||||
))) : (
|
||||
<Typography variant="body2" sx={{ color: "#888", p: 1 }}>No playbooks found.</Typography>
|
||||
)}
|
||||
</SimpleTreeView>
|
||||
</Paper>
|
||||
)}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setAddCompOpen(false)} sx={{ color: "#58a6ff" }}>Close</Button>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useMemo, useState, useCallback } from "react";
|
||||
import React, { useEffect, useState, useCallback } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
@@ -14,11 +14,11 @@ import {
|
||||
import { Folder as FolderIcon, Description as DescriptionIcon } from "@mui/icons-material";
|
||||
import { SimpleTreeView, TreeItem } from "@mui/x-tree-view";
|
||||
|
||||
function buildTree(scripts, folders) {
|
||||
function buildTree(items, folders, rootLabel = "Scripts") {
|
||||
const map = {};
|
||||
const rootNode = {
|
||||
id: "root",
|
||||
label: "Scripts",
|
||||
label: rootLabel,
|
||||
path: "",
|
||||
isFolder: true,
|
||||
children: []
|
||||
@@ -42,7 +42,7 @@ function buildTree(scripts, folders) {
|
||||
});
|
||||
});
|
||||
|
||||
(scripts || []).forEach((s) => {
|
||||
(items || []).forEach((s) => {
|
||||
const parts = (s.rel_path || "").split("/");
|
||||
let children = rootNode.children;
|
||||
let parentPath = "";
|
||||
@@ -80,13 +80,15 @@ export default function QuickJob({ open, onClose, hostnames = [] }) {
|
||||
const [running, setRunning] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [runAsCurrentUser, setRunAsCurrentUser] = useState(false);
|
||||
const [mode, setMode] = useState("scripts"); // 'scripts' | 'ansible'
|
||||
|
||||
const loadTree = useCallback(async () => {
|
||||
try {
|
||||
const resp = await fetch("/api/assembly/list?island=scripts");
|
||||
const island = mode === 'ansible' ? 'ansible' : 'scripts';
|
||||
const resp = await fetch(`/api/assembly/list?island=${island}`);
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||
const data = await resp.json();
|
||||
const { root, map } = buildTree(data.items || [], data.folders || []);
|
||||
const { root, map } = buildTree(data.items || [], data.folders || [], mode === 'ansible' ? 'Ansible Playbooks' : 'Scripts');
|
||||
setTree(root);
|
||||
setNodeMap(map);
|
||||
} catch (err) {
|
||||
@@ -94,7 +96,7 @@ export default function QuickJob({ open, onClose, hostnames = [] }) {
|
||||
setTree([]);
|
||||
setNodeMap({});
|
||||
}
|
||||
}, []);
|
||||
}, [mode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
@@ -134,19 +136,29 @@ export default function QuickJob({ open, onClose, hostnames = [] }) {
|
||||
|
||||
const onRun = async () => {
|
||||
if (!selectedPath) {
|
||||
setError("Please choose a script to run.");
|
||||
setError(mode === 'ansible' ? "Please choose a playbook to run." : "Please choose a script to run.");
|
||||
return;
|
||||
}
|
||||
setRunning(true);
|
||||
setError("");
|
||||
try {
|
||||
// quick_run expects a path relative to Assemblies root with 'Scripts/' prefix
|
||||
const script_path = selectedPath.startsWith('Scripts/') ? selectedPath : `Scripts/${selectedPath}`;
|
||||
const 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" })
|
||||
});
|
||||
let resp;
|
||||
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 })
|
||||
});
|
||||
} else {
|
||||
// quick_run expects a path relative to Assemblies root with 'Scripts/' prefix
|
||||
const script_path = selectedPath.startsWith('Scripts/') ? selectedPath : `Scripts/${selectedPath}`;
|
||||
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" })
|
||||
});
|
||||
}
|
||||
const data = await resp.json();
|
||||
if (!resp.ok) throw new Error(data.error || `HTTP ${resp.status}`);
|
||||
onClose && onClose();
|
||||
@@ -163,15 +175,19 @@ export default function QuickJob({ open, onClose, hostnames = [] }) {
|
||||
>
|
||||
<DialogTitle>Quick Job</DialogTitle>
|
||||
<DialogContent>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}>
|
||||
<Button size="small" variant={mode === 'scripts' ? 'outlined' : 'text'} onClick={() => setMode('scripts')} sx={{ textTransform: 'none', color: '#58a6ff', borderColor: '#58a6ff' }}>Scripts</Button>
|
||||
<Button size="small" variant={mode === 'ansible' ? 'outlined' : 'text'} onClick={() => setMode('ansible')} sx={{ textTransform: 'none', color: '#58a6ff', borderColor: '#58a6ff' }}>Ansible</Button>
|
||||
</Box>
|
||||
<Typography variant="body2" sx={{ color: "#aaa", mb: 1 }}>
|
||||
Select a script to run on {hostnames.length} device{hostnames.length !== 1 ? "s" : ""}.
|
||||
Select a {mode === 'ansible' ? 'playbook' : 'script'} to run on {hostnames.length} device{hostnames.length !== 1 ? "s" : ""}.
|
||||
</Typography>
|
||||
<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) : (
|
||||
<Typography variant="body2" sx={{ color: "#888", p: 1 }}>
|
||||
No scripts found.
|
||||
{mode === 'ansible' ? 'No playbooks found.' : 'No scripts found.'}
|
||||
</Typography>
|
||||
)}
|
||||
</SimpleTreeView>
|
||||
@@ -179,16 +195,20 @@ export default function QuickJob({ open, onClose, hostnames = [] }) {
|
||||
<Box sx={{ width: 320 }}>
|
||||
<Typography variant="subtitle2" sx={{ color: "#ccc", mb: 1 }}>Selection</Typography>
|
||||
<Typography variant="body2" sx={{ color: selectedPath ? "#e6edf3" : "#888" }}>
|
||||
{selectedPath || "No script selected"}
|
||||
{selectedPath || (mode === 'ansible' ? 'No playbook selected' : 'No script selected')}
|
||||
</Typography>
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<FormControlLabel
|
||||
control={<Checkbox size="small" checked={runAsCurrentUser} onChange={(e) => setRunAsCurrentUser(e.target.checked)} />}
|
||||
label={<Typography variant="body2">Run as currently logged-in user</Typography>}
|
||||
/>
|
||||
<Typography variant="caption" sx={{ color: "#888" }}>
|
||||
Unchecked = Run-As BUILTIN\SYSTEM
|
||||
</Typography>
|
||||
{mode !== 'ansible' && (
|
||||
<>
|
||||
<FormControlLabel
|
||||
control={<Checkbox size="small" checked={runAsCurrentUser} onChange={(e) => setRunAsCurrentUser(e.target.checked)} />}
|
||||
label={<Typography variant="body2">Run as currently logged-in user</Typography>}
|
||||
/>
|
||||
<Typography variant="caption" sx={{ color: "#888" }}>
|
||||
Unchecked = Run-As BUILTIN\SYSTEM
|
||||
</Typography>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
{error && (
|
||||
<Typography variant="body2" sx={{ color: "#ff4f4f", mt: 1 }}>{error}</Typography>
|
||||
@@ -207,3 +227,4 @@ export default function QuickJob({ open, onClose, hostnames = [] }) {
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user