Scaffolding Added for Ansible Playbook Execution on Agents

This commit is contained in:
2025-09-29 03:57:55 -06:00
parent 04f92184c2
commit 91c32fb16f
9 changed files with 1274 additions and 42 deletions

View File

@@ -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)}

View File

@@ -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>

View File

@@ -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>
);
}

View File

@@ -161,6 +161,70 @@ class JobScheduler:
return "bash"
return "unknown"
def _ansible_root(self) -> str:
import os
return os.path.abspath(
os.path.join(os.path.dirname(__file__), "..", "..", "Assemblies", "Ansible_Playbooks")
)
def _dispatch_ansible(self, hostname: str, rel_path: str, scheduled_job_id: int, scheduled_run_id: int) -> None:
try:
import os, json, uuid
ans_root = self._ansible_root()
rel_norm = (rel_path or "").replace("\\", "/").lstrip("/")
abs_path = os.path.abspath(os.path.join(ans_root, rel_norm))
if (not abs_path.startswith(ans_root)) or (not os.path.isfile(abs_path)):
return
try:
with open(abs_path, "r", encoding="utf-8", errors="replace") as fh:
content = fh.read()
except Exception:
return
# Record in activity_history for UI parity
now = _now_ts()
act_id = None
conn = self._conn()
cur = conn.cursor()
try:
cur.execute(
"""
INSERT INTO activity_history(hostname, script_path, script_name, script_type, ran_at, status, stdout, stderr)
VALUES(?,?,?,?,?,?,?,?)
""",
(
str(hostname),
rel_norm,
os.path.basename(abs_path),
"ansible",
now,
"Running",
"",
"",
),
)
act_id = cur.lastrowid
conn.commit()
finally:
conn.close()
payload = {
"run_id": uuid.uuid4().hex,
"target_hostname": str(hostname),
"playbook_name": os.path.basename(abs_path),
"playbook_content": content,
"activity_job_id": act_id,
"scheduled_job_id": int(scheduled_job_id),
"scheduled_run_id": int(scheduled_run_id),
"connection": "local",
}
try:
self.socketio.emit("ansible_playbook_run", payload)
except Exception:
pass
except Exception:
pass
def _dispatch_script(self, hostname: str, rel_path: str, 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.
@@ -457,12 +521,18 @@ class JobScheduler:
except Exception:
comps = []
script_paths = []
ansible_paths = []
for c in comps:
try:
if (c or {}).get("type") == "script":
ctype = (c or {}).get("type")
if ctype == "script":
p = (c.get("path") or c.get("script_path") or "").strip()
if p:
script_paths.append(p)
elif ctype == "ansible":
p = (c.get("path") or "").strip()
if p:
ansible_paths.append(p)
except Exception:
continue
run_mode = (execution_context or "system").strip().lower()
@@ -549,6 +619,7 @@ class JobScheduler:
"INSERT INTO scheduled_job_runs (job_id, target_hostname, scheduled_ts, started_ts, status, created_at, updated_at) VALUES (?,?,?,?,?,?,?)",
(job_id, host, occ, ts_now, "Running", ts_now, ts_now),
)
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:
@@ -556,6 +627,12 @@ class JobScheduler:
self._dispatch_script(host, sp, run_mode)
except Exception:
continue
# Dispatch ansible playbooks for this job to the target host
for ap in ansible_paths:
try:
self._dispatch_ansible(host, ap, job_id, run_row_id)
except Exception:
continue
except Exception:
pass
finally:

View File

@@ -1458,6 +1458,36 @@ def init_db():
"""
)
# Ansible play recap storage (one row per playbook run/session)
cur.execute(
"""
CREATE TABLE IF NOT EXISTS ansible_play_recaps (
id INTEGER PRIMARY KEY AUTOINCREMENT,
run_id TEXT UNIQUE NOT NULL,
hostname TEXT,
agent_id TEXT,
playbook_path TEXT,
playbook_name TEXT,
scheduled_job_id INTEGER,
scheduled_run_id INTEGER,
activity_job_id INTEGER,
status TEXT,
recap_text TEXT,
recap_json TEXT,
started_ts INTEGER,
finished_ts INTEGER,
created_at INTEGER,
updated_at INTEGER
)
"""
)
try:
# Helpful lookups for device views and run correlation
cur.execute("CREATE INDEX IF NOT EXISTS idx_ansible_recaps_host_created ON ansible_play_recaps(hostname, created_at)")
cur.execute("CREATE INDEX IF NOT EXISTS idx_ansible_recaps_status ON ansible_play_recaps(status)")
except Exception:
pass
conn.commit()
# Scheduled jobs table
cur.execute(
@@ -2512,6 +2542,55 @@ def scripts_quick_run():
return jsonify({"results": results})
@app.route("/api/ansible/quick_run", methods=["POST"])
def ansible_quick_run():
"""Queue an Ansible Playbook Quick Job via WebSocket to targeted agents.
Payload: { playbook_path: str, hostnames: [str] }
The playbook_path is relative to the Ansible island (e.g., "folder/play.yml").
"""
data = request.get_json(silent=True) or {}
rel_path = (data.get("playbook_path") or "").strip()
hostnames = data.get("hostnames") or []
if not rel_path or not isinstance(hostnames, list) or not hostnames:
return jsonify({"error": "Missing playbook_path or hostnames[]"}), 400
try:
root, abs_path, _ = _resolve_assembly_path('ansible', rel_path)
if not os.path.isfile(abs_path):
return jsonify({"error": "Playbook not found"}), 404
try:
with open(abs_path, 'r', encoding='utf-8', errors='replace') as fh:
content = fh.read()
except Exception as e:
return jsonify({"error": f"Failed to read playbook: {e}"}), 500
results = []
for host in hostnames:
run_id = None
try:
import uuid as _uuid
run_id = _uuid.uuid4().hex
except Exception:
run_id = str(int(time.time() * 1000))
payload = {
"run_id": run_id,
"target_hostname": str(host),
"playbook_name": os.path.basename(abs_path),
"playbook_content": content,
"connection": "local",
}
try:
socketio.emit("ansible_playbook_run", payload)
except Exception:
pass
results.append({"hostname": host, "run_id": run_id, "status": "Queued"})
return jsonify({"results": results})
except ValueError as ve:
return jsonify({"error": str(ve)}), 400
except Exception as e:
return jsonify({"error": str(e)}), 500
@app.route("/api/device/activity/<hostname>", methods=["GET", "DELETE"])
def device_activity(hostname: str):
try:
@@ -2598,6 +2677,309 @@ def handle_quick_job_result(data):
print(f"[ERROR] quick_job_result DB update failed for job {job_id}: {e}")
# ---------------------------------------------
# Ansible Runtime API (Play Recaps)
# ---------------------------------------------
def _json_dump_safe(obj) -> str:
try:
if isinstance(obj, str):
# Accept pre-serialized JSON strings as-is
json.loads(obj)
return obj
return json.dumps(obj or {})
except Exception:
return json.dumps({})
@app.route("/api/ansible/recap/report", methods=["POST"])
def api_ansible_recap_report():
"""Create or update an Ansible recap row for a running/finished playbook.
Expects JSON body with fields:
run_id: str (required) unique id for this playbook run (uuid recommended)
hostname: str (optional)
agent_id: str (optional)
playbook_path: str (optional)
playbook_name: str (optional)
scheduled_job_id: int (optional)
scheduled_run_id: int (optional)
activity_job_id: int (optional)
status: str (Running|Success|Failed|Cancelled) (optional)
recap_text: str (optional)
recap_json: object or str (optional)
started_ts: int (optional)
finished_ts: int (optional)
"""
data = request.get_json(silent=True) or {}
run_id = (data.get("run_id") or "").strip()
if not run_id:
return jsonify({"error": "run_id is required"}), 400
now = _now_ts()
hostname = (data.get("hostname") or "").strip()
agent_id = (data.get("agent_id") or "").strip()
playbook_path = (data.get("playbook_path") or "").strip()
playbook_name = (data.get("playbook_name") or "").strip() or (os.path.basename(playbook_path) if playbook_path else "")
status = (data.get("status") or "").strip()
recap_text = data.get("recap_text")
recap_json = data.get("recap_json")
# IDs to correlate with other subsystems (optional)
try:
scheduled_job_id = int(data.get("scheduled_job_id")) if data.get("scheduled_job_id") is not None else None
except Exception:
scheduled_job_id = None
try:
scheduled_run_id = int(data.get("scheduled_run_id")) if data.get("scheduled_run_id") is not None else None
except Exception:
scheduled_run_id = None
try:
activity_job_id = int(data.get("activity_job_id")) if data.get("activity_job_id") is not None else None
except Exception:
activity_job_id = None
try:
started_ts = int(data.get("started_ts")) if data.get("started_ts") is not None else None
except Exception:
started_ts = None
try:
finished_ts = int(data.get("finished_ts")) if data.get("finished_ts") is not None else None
except Exception:
finished_ts = None
recap_json_str = _json_dump_safe(recap_json) if recap_json is not None else None
try:
conn = _db_conn()
cur = conn.cursor()
# Attempt update by run_id first
cur.execute(
"SELECT id FROM ansible_play_recaps WHERE run_id = ?",
(run_id,)
)
row = cur.fetchone()
if row:
recap_id = int(row[0])
cur.execute(
"""
UPDATE ansible_play_recaps
SET hostname = COALESCE(?, hostname),
agent_id = COALESCE(?, agent_id),
playbook_path = COALESCE(?, playbook_path),
playbook_name = COALESCE(?, playbook_name),
scheduled_job_id = COALESCE(?, scheduled_job_id),
scheduled_run_id = COALESCE(?, scheduled_run_id),
activity_job_id = COALESCE(?, activity_job_id),
status = COALESCE(?, status),
recap_text = CASE WHEN ? IS NOT NULL THEN ? ELSE recap_text END,
recap_json = CASE WHEN ? IS NOT NULL THEN ? ELSE recap_json END,
started_ts = COALESCE(?, started_ts),
finished_ts = COALESCE(?, finished_ts),
updated_at = ?
WHERE run_id = ?
""",
(
hostname or None,
agent_id or None,
playbook_path or None,
playbook_name or None,
scheduled_job_id,
scheduled_run_id,
activity_job_id,
status or None,
recap_text, recap_text,
recap_json_str, recap_json_str,
started_ts,
finished_ts,
now,
run_id,
)
)
conn.commit()
else:
cur.execute(
"""
INSERT INTO ansible_play_recaps (
run_id, hostname, agent_id, playbook_path, playbook_name,
scheduled_job_id, scheduled_run_id, activity_job_id,
status, recap_text, recap_json, started_ts, finished_ts,
created_at, updated_at
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
""",
(
run_id,
hostname or None,
agent_id or None,
playbook_path or None,
playbook_name or None,
scheduled_job_id,
scheduled_run_id,
activity_job_id,
status or None,
recap_text if recap_text is not None else None,
recap_json_str,
started_ts,
finished_ts,
now,
now,
)
)
recap_id = cur.lastrowid
conn.commit()
# If linked to an activity_history row, mirror status/stdout for Activity tab UX
try:
if activity_job_id:
cur.execute(
"UPDATE activity_history SET status = COALESCE(?, status), stdout = CASE WHEN ? IS NOT NULL THEN ? ELSE stdout END WHERE id = ?",
(status or None, recap_text, recap_text, activity_job_id)
)
conn.commit()
except Exception:
pass
# Return the latest row
cur.execute(
"SELECT id, run_id, hostname, agent_id, playbook_path, playbook_name, scheduled_job_id, scheduled_run_id, activity_job_id, status, recap_text, recap_json, started_ts, finished_ts, created_at, updated_at FROM ansible_play_recaps WHERE id=?",
(recap_id,)
)
row = cur.fetchone()
conn.close()
# Broadcast to connected clients for live updates
try:
payload = {
"id": row[0],
"run_id": row[1],
"hostname": row[2] or "",
"agent_id": row[3] or "",
"playbook_path": row[4] or "",
"playbook_name": row[5] or "",
"scheduled_job_id": row[6],
"scheduled_run_id": row[7],
"activity_job_id": row[8],
"status": row[9] or "",
"recap_text": row[10] or "",
"recap_json": json.loads(row[11]) if (row[11] or "").strip() else None,
"started_ts": row[12],
"finished_ts": row[13],
"created_at": row[14],
"updated_at": row[15],
}
socketio.emit("ansible_recap_update", payload)
except Exception:
pass
return jsonify({
"id": row[0],
"run_id": row[1],
"hostname": row[2] or "",
"agent_id": row[3] or "",
"playbook_path": row[4] or "",
"playbook_name": row[5] or "",
"scheduled_job_id": row[6],
"scheduled_run_id": row[7],
"activity_job_id": row[8],
"status": row[9] or "",
"recap_text": row[10] or "",
"recap_json": json.loads(row[11]) if (row[11] or "").strip() else None,
"started_ts": row[12],
"finished_ts": row[13],
"created_at": row[14],
"updated_at": row[15],
})
except Exception as e:
return jsonify({"error": str(e)}), 500
@app.route("/api/ansible/recaps", methods=["GET"])
def api_ansible_recaps_list():
"""List Ansible play recaps. Optional query params: hostname, limit (default 50)"""
hostname = (request.args.get("hostname") or "").strip()
try:
limit = int(request.args.get("limit") or 50)
except Exception:
limit = 50
try:
conn = _db_conn()
cur = conn.cursor()
if hostname:
cur.execute(
"""
SELECT id, run_id, hostname, playbook_name, status, created_at, updated_at, started_ts, finished_ts
FROM ansible_play_recaps
WHERE hostname = ?
ORDER BY COALESCE(updated_at, created_at) DESC, id DESC
LIMIT ?
""",
(hostname, limit)
)
else:
cur.execute(
"""
SELECT id, run_id, hostname, playbook_name, status, created_at, updated_at, started_ts, finished_ts
FROM ansible_play_recaps
ORDER BY COALESCE(updated_at, created_at) DESC, id DESC
LIMIT ?
""",
(limit,)
)
rows = cur.fetchall()
conn.close()
out = []
for r in rows:
out.append({
"id": r[0],
"run_id": r[1],
"hostname": r[2] or "",
"playbook_name": r[3] or "",
"status": r[4] or "",
"created_at": r[5],
"updated_at": r[6],
"started_ts": r[7],
"finished_ts": r[8],
})
return jsonify({"recaps": out})
except Exception as e:
return jsonify({"error": str(e)}), 500
@app.route("/api/ansible/recap/<int:recap_id>", methods=["GET"])
def api_ansible_recap_get(recap_id: int):
try:
conn = _db_conn()
cur = conn.cursor()
cur.execute(
"SELECT id, run_id, hostname, agent_id, playbook_path, playbook_name, scheduled_job_id, scheduled_run_id, activity_job_id, status, recap_text, recap_json, started_ts, finished_ts, created_at, updated_at FROM ansible_play_recaps WHERE id=?",
(recap_id,)
)
row = cur.fetchone()
conn.close()
if not row:
return jsonify({"error": "Not found"}), 404
return jsonify({
"id": row[0],
"run_id": row[1],
"hostname": row[2] or "",
"agent_id": row[3] or "",
"playbook_path": row[4] or "",
"playbook_name": row[5] or "",
"scheduled_job_id": row[6],
"scheduled_run_id": row[7],
"activity_job_id": row[8],
"status": row[9] or "",
"recap_text": row[10] or "",
"recap_json": json.loads(row[11]) if (row[11] or "").strip() else None,
"started_ts": row[12],
"finished_ts": row[13],
"created_at": row[14],
"updated_at": row[15],
})
except Exception as e:
return jsonify({"error": str(e)}), 500
@socketio.on("collector_status")
def handle_collector_status(data):
"""Collector agent reports activity and optional last_user.
@@ -2992,6 +3374,21 @@ def handle_agent_window_list(data):
# Relay the list to all interested clients
socketio.emit("agent_window_list", data)
# Relay Ansible control messages from UI to agents
@socketio.on("ansible_playbook_cancel")
def relay_ansible_cancel(data):
try:
socketio.emit("ansible_playbook_cancel", data)
except Exception:
pass
@socketio.on("ansible_playbook_run")
def relay_ansible_run(data):
try:
socketio.emit("ansible_playbook_run", data)
except Exception:
pass
# ---------------------------------------------
# Server Launch
# ---------------------------------------------