Additional Changes to Ansible Logic

This commit is contained in:
2025-10-11 18:58:55 -06:00
parent c0f47075d6
commit 8cae44539c
9 changed files with 596 additions and 146 deletions

View File

@@ -0,0 +1,16 @@
{
"version": 1,
"name": "Query OS String [LINUX]",
"description": "Simply returns the operating system in the output.",
"category": "application",
"type": "ansible",
"script": "LS0tCi0gbmFtZTogR2V0IG9wZXJhdGluZyBzeXN0ZW0gaW5mb3JtYXRpb24KICBob3N0czogYWxsCiAgZ2F0aGVyX2ZhY3RzOiB5ZXMKCiAgdGFza3M6CiAgICAtIG5hbWU6IFByaW50IE9TIHN0cmluZwogICAgICBkZWJ1ZzoKICAgICAgICBtc2c6ICJ7eyBhbnNpYmxlX2Rpc3RyaWJ1dGlvbiB9fSB7eyBhbnNpYmxlX2Rpc3RyaWJ1dGlvbl92ZXJzaW9uIH19IHt7IGFuc2libGVfZGlzdHJpYnV0aW9uX3JlbGVhc2UgfCBkZWZhdWx0KCcnKSB9fSIK",
"timeout_seconds": 3600,
"sites": {
"mode": "all",
"values": []
},
"variables": [],
"files": [],
"script_encoding": "base64"
}

View File

@@ -35,7 +35,9 @@ import Login from "./Login.jsx";
import SiteList from "./Sites/Site_List";
import DeviceList from "./Devices/Device_List";
import DeviceDetails from "./Devices/Device_Details";
import AgentDevices from "./Devices/Agent_Devices.jsx";
import SSHDevices from "./Devices/SSH_Devices.jsx";
import WinRMDevices from "./Devices/WinRM_Devices.jsx";
import AssemblyList from "./Assemblies/Assembly_List";
import AssemblyEditor from "./Assemblies/Assembly_Editor";
import ScheduledJobsList from "./Scheduling/Scheduled_Jobs_List";
@@ -160,8 +162,8 @@ const LOCAL_STORAGE_KEY = "borealis_persistent_state";
items.push({ label: "Site List", page: "sites" });
break;
case "devices":
items.push({ label: "Inventory", page: "devices" });
items.push({ label: "Devices", page: "devices" });
items.push({ label: "Device List", page: "devices" });
break;
case "device_details":
items.push({ label: "Devices", page: "devices" });
@@ -203,14 +205,21 @@ const LOCAL_STORAGE_KEY = "borealis_persistent_state";
items.push({ label: "Automation", page: "jobs" });
items.push({ label: "Community Content", page: "community" });
break;
case "devices":
case "agent_devices":
items.push({ label: "Inventory", page: "devices" });
items.push({ label: "Devices", page: "devices" });
items.push({ label: "Agent Devices", page: "agent_devices" });
break;
case "ssh_devices":
items.push({ label: "Inventory", page: "devices" });
items.push({ label: "Devices", page: "devices" });
items.push({ label: "SSH Devices", page: "ssh_devices" });
break;
case "winrm_devices":
items.push({ label: "Inventory", page: "devices" });
items.push({ label: "Devices", page: "devices" });
items.push({ label: "WinRM Devices", page: "winrm_devices" });
break;
case "access_credentials":
items.push({ label: "Access Management", page: "access_credentials" });
items.push({ label: "Credentials", page: "access_credentials" });
@@ -565,7 +574,13 @@ const LOCAL_STORAGE_KEY = "borealis_persistent_state";
const isAdmin = (String(userRole || '').toLowerCase() === 'admin');
useEffect(() => {
if (!isAdmin && (currentPage === 'server_info' || currentPage === 'access_credentials' || currentPage === 'access_users' || currentPage === 'ssh_devices')) {
const requiresAdmin = currentPage === 'server_info'
|| currentPage === 'access_credentials'
|| currentPage === 'access_users'
|| currentPage === 'ssh_devices'
|| currentPage === 'winrm_devices'
|| currentPage === 'agent_devices';
if (!isAdmin && requiresAdmin) {
setNotAuthorizedOpen(true);
setCurrentPage('devices');
}
@@ -593,8 +608,19 @@ const LOCAL_STORAGE_KEY = "borealis_persistent_state";
}}
/>
);
case "agent_devices":
return (
<AgentDevices
onSelectDevice={(d) => {
setSelectedDevice(d);
setCurrentPage("device_details");
}}
/>
);
case "ssh_devices":
return <SSHDevices />;
case "winrm_devices":
return <WinRMDevices />;
case "device_details":
return (

View File

@@ -0,0 +1,219 @@
import React, { useEffect, useState } from "react";
import {
Dialog,
DialogTitle,
DialogContent,
DialogActions,
TextField,
Button,
MenuItem,
Typography
} from "@mui/material";
const TYPE_OPTIONS = [
{ value: "ssh", label: "SSH" },
{ value: "winrm", label: "WinRM" }
];
const initialForm = {
hostname: "",
address: "",
description: "",
operating_system: ""
};
export default function AddDevice({
open,
onClose,
defaultType = null,
onCreated
}) {
const [type, setType] = useState(defaultType || "ssh");
const [form, setForm] = useState(initialForm);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState("");
useEffect(() => {
if (open) {
setType(defaultType || "ssh");
setForm(initialForm);
setError("");
}
}, [open, defaultType]);
const handleClose = () => {
if (submitting) return;
onClose && onClose();
};
const handleChange = (field) => (event) => {
const value = event.target.value;
setForm((prev) => ({ ...prev, [field]: value }));
};
const handleSubmit = async () => {
if (submitting) return;
const trimmedHostname = form.hostname.trim();
const trimmedAddress = form.address.trim();
if (!trimmedHostname) {
setError("Hostname is required.");
return;
}
if (!type) {
setError("Select a device type.");
return;
}
if (!trimmedAddress) {
setError("Address is required.");
return;
}
setSubmitting(true);
setError("");
const payload = {
hostname: trimmedHostname,
address: trimmedAddress,
description: form.description.trim(),
operating_system: form.operating_system.trim()
};
const apiBase = type === "winrm" ? "/api/winrm_devices" : "/api/ssh_devices";
try {
const resp = await fetch(apiBase, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
});
const data = await resp.json().catch(() => ({}));
if (!resp.ok) throw new Error(data?.error || `HTTP ${resp.status}`);
onCreated && onCreated(data.device || null);
onClose && onClose();
} catch (err) {
setError(String(err.message || err));
} finally {
setSubmitting(false);
}
};
const dialogTitle = defaultType
? `Add ${defaultType.toUpperCase()} Device`
: "Add Device";
const typeLabel = (TYPE_OPTIONS.find((opt) => opt.value === type) || TYPE_OPTIONS[0]).label;
return (
<Dialog
open={open}
onClose={handleClose}
fullWidth
maxWidth="sm"
PaperProps={{ sx: { bgcolor: "#121212", color: "#fff" } }}
>
<DialogTitle>{dialogTitle}</DialogTitle>
<DialogContent sx={{ display: "flex", flexDirection: "column", gap: 2, mt: 1 }}>
{!defaultType && (
<TextField
select
label="Device Type"
size="small"
value={type}
onChange={(e) => setType(e.target.value)}
sx={{
"& .MuiOutlinedInput-root": {
backgroundColor: "#1f1f1f",
color: "#fff",
"& fieldset": { borderColor: "#555" },
"&:hover fieldset": { borderColor: "#888" }
},
"& .MuiInputLabel-root": { color: "#aaa" }
}}
>
{TYPE_OPTIONS.map((opt) => (
<MenuItem key={opt.value} value={opt.value}>
{opt.label}
</MenuItem>
))}
</TextField>
)}
<TextField
label="Hostname"
value={form.hostname}
onChange={handleChange("hostname")}
size="small"
sx={{
"& .MuiOutlinedInput-root": {
backgroundColor: "#1f1f1f",
color: "#fff",
"& fieldset": { borderColor: "#555" },
"&:hover fieldset": { borderColor: "#888" }
},
"& .MuiInputLabel-root": { color: "#aaa" }
}}
helperText="Name used inside Borealis."
/>
<TextField
label={`${typeLabel} Address`}
value={form.address}
onChange={handleChange("address")}
size="small"
sx={{
"& .MuiOutlinedInput-root": {
backgroundColor: "#1f1f1f",
color: "#fff",
"& fieldset": { borderColor: "#555" },
"&:hover fieldset": { borderColor: "#888" }
},
"& .MuiInputLabel-root": { color: "#aaa" }
}}
helperText="IP or FQDN reachable from the Borealis server."
/>
<TextField
label="Description"
value={form.description}
onChange={handleChange("description")}
size="small"
sx={{
"& .MuiOutlinedInput-root": {
backgroundColor: "#1f1f1f",
color: "#fff",
"& fieldset": { borderColor: "#555" },
"&:hover fieldset": { borderColor: "#888" }
},
"& .MuiInputLabel-root": { color: "#aaa" }
}}
/>
<TextField
label="Operating System"
value={form.operating_system}
onChange={handleChange("operating_system")}
size="small"
sx={{
"& .MuiOutlinedInput-root": {
backgroundColor: "#1f1f1f",
color: "#fff",
"& fieldset": { borderColor: "#555" },
"&:hover fieldset": { borderColor: "#888" }
},
"& .MuiInputLabel-root": { color: "#aaa" }
}}
/>
{error && (
<Typography variant="body2" sx={{ color: "#ff8080" }}>
{error}
</Typography>
)}
</DialogContent>
<DialogActions sx={{ px: 3, pb: 2 }}>
<Button onClick={handleClose} sx={{ color: "#58a6ff" }} disabled={submitting}>
Cancel
</Button>
<Button
onClick={handleSubmit}
variant="outlined"
sx={{ color: "#58a6ff", borderColor: "#58a6ff" }}
disabled={submitting}
>
{submitting ? "Saving..." : "Save"}
</Button>
</DialogActions>
</Dialog>
);
}

View File

@@ -0,0 +1,13 @@
import React from "react";
import DeviceList from "./Device_List.jsx";
export default function AgentDevices(props) {
return (
<DeviceList
{...props}
filterMode="agent"
title="Agent Devices"
showAddButton={false}
/>
);
}

View File

@@ -27,6 +27,7 @@ import AddIcon from "@mui/icons-material/Add";
import CachedIcon from "@mui/icons-material/Cached";
import { DeleteDeviceDialog, CreateCustomViewDialog, RenameCustomViewDialog } from "../Dialogs.jsx";
import QuickJob from "../Scheduling/Quick_Job.jsx";
import AddDevice from "./Add_Device.jsx";
function formatLastSeen(tsSec, offlineAfter = 300) {
if (!tsSec) return "unknown";
@@ -66,7 +67,14 @@ function formatUptime(seconds) {
return parts.join(' ');
}
export default function DeviceList({ onSelectDevice }) {
export default function DeviceList({
onSelectDevice,
filterMode = "all",
title,
showAddButton,
addButtonLabel,
defaultAddType,
}) {
const [rows, setRows] = useState([]);
const [orderBy, setOrderBy] = useState("status");
const [order, setOrder] = useState("desc");
@@ -76,6 +84,36 @@ export default function DeviceList({ onSelectDevice }) {
// Track selection by agent id to avoid duplicate hostname collisions
const [selectedIds, setSelectedIds] = useState(() => new Set());
const [quickJobOpen, setQuickJobOpen] = useState(false);
const [addDeviceOpen, setAddDeviceOpen] = useState(false);
const [addDeviceType, setAddDeviceType] = useState(null);
const computedTitle = useMemo(() => {
if (title) return title;
switch (filterMode) {
case "agent":
return "Agent Devices";
case "ssh":
return "SSH Devices";
case "winrm":
return "WinRM Devices";
default:
return "Device Inventory";
}
}, [filterMode, title]);
const derivedDefaultType = useMemo(() => {
if (defaultAddType !== undefined) return defaultAddType;
if (filterMode === "ssh" || filterMode === "winrm") return filterMode;
return null;
}, [defaultAddType, filterMode]);
const derivedAddLabel = useMemo(() => {
if (addButtonLabel) return addButtonLabel;
if (filterMode === "ssh") return "Add SSH Device";
if (filterMode === "winrm") return "Add WinRM Device";
return "Add Device";
}, [addButtonLabel, filterMode]);
const derivedShowAddButton = useMemo(() => {
if (typeof showAddButton === "boolean") return showAddButton;
return filterMode !== "agent";
}, [showAddButton, filterMode]);
// Saved custom views (from server)
const [views, setViews] = useState([]); // [{id, name, columns:[id], filters:{}}]
@@ -305,6 +343,7 @@ export default function DeviceList({ onSelectDevice }) {
0
) || 0;
const connectionType = (device.connection_type || summary.connection_type || '').trim().toLowerCase();
const connectionLabel = connectionType === 'ssh' ? 'SSH' : connectionType === 'winrm' ? 'WinRM' : '';
const connectionEndpoint = (device.connection_endpoint || summary.connection_endpoint || '').trim();
const memoryList = Array.isArray(device.memory) ? device.memory : [];
@@ -329,7 +368,7 @@ export default function DeviceList({ onSelectDevice }) {
lastSeenDisplay: formatLastSeen(lastSeen),
os: osName,
lastUser,
type,
type: type || connectionLabel || '',
site: device.site_name || 'Not Configured',
siteId: device.site_id || null,
siteDescription: device.site_description || '',
@@ -360,17 +399,27 @@ export default function DeviceList({ onSelectDevice }) {
summary,
details: device.details || {},
connectionType,
connectionLabel,
connectionEndpoint,
isRemote: connectionType === 'ssh',
isRemote: Boolean(connectionLabel),
};
});
setRows(normalized);
let filtered = normalized;
if (filterMode === "agent") {
filtered = normalized.filter((row) => !row.connectionType);
} else if (filterMode === "ssh") {
filtered = normalized.filter((row) => row.connectionType === "ssh");
} else if (filterMode === "winrm") {
filtered = normalized.filter((row) => row.connectionType === "winrm");
}
setRows(filtered);
} catch (e) {
console.warn('Failed to load devices:', e);
setRows([]);
}
}, [repoHash, fetchLatestRepoHash, computeAgentVersion]);
}, [repoHash, fetchLatestRepoHash, computeAgentVersion, filterMode]);
const fetchViews = useCallback(async () => {
try {
@@ -653,7 +702,7 @@ export default function DeviceList({ onSelectDevice }) {
<Box sx={{ p: 2, pb: 1, display: "flex", flexDirection: 'column', gap: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Typography variant="h6" sx={{ color: "#58a6ff", mb: 0 }}>
Device Inventory
{computedTitle}
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center' }}>
{/* Views dropdown + add button */}
@@ -753,6 +802,20 @@ export default function DeviceList({ onSelectDevice }) {
<ViewColumnIcon fontSize="small" />
</IconButton>
</Tooltip>
{derivedShowAddButton && (
<Button
variant="contained"
size="small"
startIcon={<AddIcon />}
sx={{ bgcolor: "#58a6ff", color: "#0b0f19" }}
onClick={() => {
setAddDeviceType(derivedDefaultType ?? null);
setAddDeviceOpen(true);
}}
>
{derivedAddLabel}
</Button>
)}
</Box>
</Box>
{/* Second row: Quick Job button aligned under header title */}
@@ -1224,6 +1287,19 @@ export default function DeviceList({ onSelectDevice }) {
</Box>
</Popover>
)}
<AddDevice
open={addDeviceOpen}
defaultType={addDeviceType}
onClose={() => {
setAddDeviceOpen(false);
setAddDeviceType(derivedDefaultType ?? null);
}}
onCreated={() => {
setAddDeviceOpen(false);
setAddDeviceType(derivedDefaultType ?? null);
fetchDevices({ refreshRepo: true });
}}
/>
</Paper>
);
}

View File

@@ -23,6 +23,7 @@ import EditIcon from "@mui/icons-material/Edit";
import DeleteIcon from "@mui/icons-material/Delete";
import RefreshIcon from "@mui/icons-material/Refresh";
import { ConfirmDeleteDialog } from "../Dialogs.jsx";
import AddDevice from "./Add_Device.jsx";
const tableStyles = {
"& th, & td": {
@@ -45,7 +46,19 @@ const defaultForm = {
operating_system: ""
};
export default function SSHDevices() {
export default function SSHDevices({ type = "ssh" }) {
const typeLabel = type === "winrm" ? "WinRM" : "SSH";
const apiBase = type === "winrm" ? "/api/winrm_devices" : "/api/ssh_devices";
const pageTitle = `${typeLabel} Devices`;
const addButtonLabel = `Add ${typeLabel} Device`;
const addressLabel = `${typeLabel} Address`;
const loadingLabel = `Loading ${typeLabel} devices…`;
const emptyLabel = `No ${typeLabel} devices have been added yet.`;
const descriptionText = type === "winrm"
? "Manage remote endpoints reachable via WinRM for playbook execution."
: "Manage remote endpoints reachable via SSH for playbook execution.";
const editDialogTitle = `Edit ${typeLabel} Device`;
const newDialogTitle = `New ${typeLabel} Device`;
const [rows, setRows] = useState([]);
const [orderBy, setOrderBy] = useState("hostname");
const [order, setOrder] = useState("asc");
@@ -58,6 +71,7 @@ export default function SSHDevices() {
const [editTarget, setEditTarget] = useState(null);
const [deleteTarget, setDeleteTarget] = useState(null);
const [deleteBusy, setDeleteBusy] = useState(false);
const [addDeviceOpen, setAddDeviceOpen] = useState(false);
const isEdit = Boolean(editTarget);
@@ -65,7 +79,7 @@ export default function SSHDevices() {
setLoading(true);
setError("");
try {
const resp = await fetch("/api/ssh_devices");
const resp = await fetch(apiBase);
if (!resp.ok) {
const data = await resp.json().catch(() => ({}));
throw new Error(data?.error || `HTTP ${resp.status}`);
@@ -79,7 +93,7 @@ export default function SSHDevices() {
} finally {
setLoading(false);
}
}, []);
}, [apiBase]);
useEffect(() => {
loadDevices();
@@ -119,9 +133,7 @@ export default function SSHDevices() {
};
const openCreate = () => {
setEditTarget(null);
setForm(defaultForm);
setDialogOpen(true);
setAddDeviceOpen(true);
setFormError("");
};
@@ -164,16 +176,14 @@ export default function SSHDevices() {
setSubmitting(true);
setFormError("");
try {
const resp = await fetch(
isEdit
? `/api/ssh_devices/${encodeURIComponent(editTarget.hostname)}`
: "/api/ssh_devices",
{
const endpoint = isEdit
? `${apiBase}/${encodeURIComponent(editTarget.hostname)}`
: apiBase;
const resp = await fetch(endpoint, {
method: isEdit ? "PUT" : "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
}
);
});
const data = await resp.json().catch(() => ({}));
if (!resp.ok) {
throw new Error(data?.error || `HTTP ${resp.status}`);
@@ -205,7 +215,7 @@ export default function SSHDevices() {
if (!deleteTarget) return;
setDeleteBusy(true);
try {
const resp = await fetch(`/api/ssh_devices/${encodeURIComponent(deleteTarget.hostname)}`, {
const resp = await fetch(`${apiBase}/${encodeURIComponent(deleteTarget.hostname)}`, {
method: "DELETE"
});
const data = await resp.json().catch(() => ({}));
@@ -232,10 +242,10 @@ export default function SSHDevices() {
>
<Box>
<Typography variant="h6" sx={{ color: "#58a6ff", mb: 0 }}>
SSH Devices
{pageTitle}
</Typography>
<Typography variant="body2" sx={{ color: "#aaa" }}>
Manage remote endpoints reachable via SSH for playbook execution.
{descriptionText}
</Typography>
</Box>
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
@@ -256,7 +266,7 @@ export default function SSHDevices() {
sx={{ bgcolor: "#58a6ff", color: "#0b0f19" }}
onClick={openCreate}
>
New SSH Device
{addButtonLabel}
</Button>
</Box>
</Box>
@@ -269,7 +279,7 @@ export default function SSHDevices() {
{loading && (
<Box sx={{ px: 2, py: 1.5, display: "flex", alignItems: "center", gap: 1, color: "#7db7ff" }}>
<CircularProgress size={18} sx={{ color: "#58a6ff" }} />
<Typography variant="body2">Loading SSH devices</Typography>
<Typography variant="body2">{loadingLabel}</Typography>
</Box>
)}
@@ -291,7 +301,7 @@ export default function SSHDevices() {
direction={orderBy === "address" ? order : "asc"}
onClick={handleSort("address")}
>
SSH Address
{addressLabel}
</TableSortLabel>
</TableCell>
<TableCell sortDirection={orderBy === "description" ? order : false}>
@@ -341,7 +351,7 @@ export default function SSHDevices() {
{!sortedRows.length && !loading && (
<TableRow>
<TableCell colSpan={5} sx={{ textAlign: "center", color: "#888" }}>
No SSH devices have been added yet.
{emptyLabel}
</TableCell>
</TableRow>
)}
@@ -355,7 +365,7 @@ export default function SSHDevices() {
maxWidth="sm"
PaperProps={{ sx: { bgcolor: "#121212", color: "#fff" } }}
>
<DialogTitle>{isEdit ? "Edit SSH Device" : "New SSH Device"}</DialogTitle>
<DialogTitle>{isEdit ? editDialogTitle : newDialogTitle}</DialogTitle>
<DialogContent sx={{ display: "flex", flexDirection: "column", gap: 2, mt: 1 }}>
<TextField
label="Hostname"
@@ -376,7 +386,7 @@ export default function SSHDevices() {
helperText="Hostname used within Borealis (unique)."
/>
<TextField
label="SSH Address"
label={addressLabel}
value={form.address}
onChange={(e) => setForm((prev) => ({ ...prev, address: e.target.value }))}
fullWidth
@@ -390,7 +400,7 @@ export default function SSHDevices() {
},
"& .MuiInputLabel-root": { color: "#aaa" }
}}
helperText="IP or FQDN Borealis can reach over SSH."
helperText={`IP or FQDN Borealis can reach over ${typeLabel}.`}
/>
<TextField
label="Description"
@@ -449,13 +459,22 @@ export default function SSHDevices() {
open={Boolean(deleteTarget)}
message={
deleteTarget
? `Remove SSH device '${deleteTarget.hostname}' from inventory?`
? `Remove ${typeLabel} device '${deleteTarget.hostname}' from inventory?`
: ""
}
onCancel={() => setDeleteTarget(null)}
onConfirm={handleDelete}
confirmDisabled={deleteBusy}
/>
<AddDevice
open={addDeviceOpen}
defaultType={type}
onClose={() => setAddDeviceOpen(false)}
onCreated={() => {
setAddDeviceOpen(false);
loadDevices();
}}
/>
</Paper>
);
}

View File

@@ -0,0 +1,6 @@
import React from "react";
import SSHDevices from "./SSH_Devices.jsx";
export default function WinRMDevices(props) {
return <SSHDevices {...props} type="winrm" />;
}

View File

@@ -154,7 +154,7 @@ function NavigationSidebar({ currentPage, onNavigate, isAdmin = false }) {
})()}
{/* Inventory */}
{(() => {
const groupActive = currentPage === "devices" || currentPage === "ssh_devices";
const groupActive = ["devices", "ssh_devices", "winrm_devices", "agent_devices"].includes(currentPage);
return (
<Accordion
expanded={expandedNav.devices}
@@ -192,7 +192,9 @@ function NavigationSidebar({ currentPage, onNavigate, isAdmin = false }) {
</AccordionSummary>
<AccordionDetails sx={{ p: 0, bgcolor: "#232323" }}>
<NavItem icon={<DevicesIcon fontSize="small" />} label="Devices" pageKey="devices" />
<NavItem icon={<DevicesIcon fontSize="small" />} label="Agent Devices" pageKey="agent_devices" indent />
<NavItem icon={<DevicesIcon fontSize="small" />} label="SSH Devices" pageKey="ssh_devices" indent />
<NavItem icon={<DevicesIcon fontSize="small" />} label="WinRM Devices" pageKey="winrm_devices" indent />
</AccordionDetails>
</Accordion>
);

View File

@@ -19,8 +19,10 @@ Section Guide:
"""
import eventlet
# Monkey-patch stdlib for cooperative sockets
eventlet.monkey_patch()
# Monkey-patch stdlib for cooperative sockets (keep real threads for tpool usage)
eventlet.monkey_patch(thread=False)
from eventlet import tpool
import requests
import re
@@ -43,6 +45,7 @@ import subprocess
import stat
import traceback
from threading import Lock
from datetime import datetime, timezone
try:
@@ -2045,7 +2048,7 @@ def _queue_server_ansible_run(
"started_ts": _now_ts(),
}
try:
socketio.start_background_task(_execute_server_ansible_run, ctx)
socketio.start_background_task(_execute_server_ansible_run, ctx, None)
except Exception as exc:
_ansible_log_server(f"[server_run] failed to queue background task run_id={run_id}: {exc}")
_execute_server_ansible_run(ctx, immediate_error=str(exc))
@@ -2147,7 +2150,8 @@ def _execute_server_ansible_run(ctx: Dict[str, Any], immediate_error: Optional[s
f"[server_run] start run_id={run_id} host='{hostname}' playbook='{playbook_rel_path}' credential={credential_id}"
)
proc = subprocess.run(
proc = tpool.execute(
subprocess.run,
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
@@ -4967,6 +4971,7 @@ def _fetch_devices(
*,
connection_type: Optional[str] = None,
hostname: Optional[str] = None,
only_agents: bool = False,
) -> List[Dict[str, Any]]:
try:
conn = _db_conn()
@@ -4986,6 +4991,8 @@ def _fetch_devices(
if hostname:
clauses.append("LOWER(d.hostname) = LOWER(?)")
params.append(hostname.lower())
if only_agents:
clauses.append("(d.connection_type IS NULL OR TRIM(d.connection_type) = '')")
if clauses:
sql += " WHERE " + " AND ".join(clauses)
cur.execute(sql, params)
@@ -5060,21 +5067,105 @@ def list_devices():
return jsonify({"devices": devices})
@app.route("/api/ssh_devices", methods=["GET", "POST"])
def api_ssh_devices():
def _upsert_remote_device(
connection_type: str,
hostname: str,
address: Optional[str],
description: Optional[str],
os_hint: Optional[str],
*,
ensure_existing_type: Optional[str],
) -> Dict[str, Any]:
conn = _db_conn()
cur = conn.cursor()
existing = _load_device_snapshot(cur, hostname=hostname)
existing_type = (existing or {}).get("summary", {}).get("connection_type") or ""
existing_type = existing_type.strip().lower()
if ensure_existing_type and existing_type != ensure_existing_type.lower():
conn.close()
raise ValueError("device not found")
if ensure_existing_type is None and existing_type and existing_type != connection_type.lower():
conn.close()
raise ValueError("device already exists with different connection type")
created_ts = existing.get("summary", {}).get("created_at") if existing else None
if not created_ts:
created_ts = _now_ts()
endpoint = address or (existing.get("summary", {}).get("connection_endpoint") if existing else "")
if not endpoint:
conn.close()
raise ValueError("address is required")
description_val = description
if description_val is None:
description_val = existing.get("summary", {}).get("description") if existing else ""
os_value = os_hint or (existing.get("summary", {}).get("operating_system") if existing else "")
os_value = os_value or ""
device_type_label = "SSH Remote" if connection_type.lower() == "ssh" else "WinRM Remote"
summary_payload = {
"connection_type": connection_type.lower(),
"connection_endpoint": endpoint,
"internal_ip": endpoint,
"external_ip": endpoint,
"device_type": device_type_label,
"operating_system": os_value,
"last_seen": 0,
}
_device_upsert(
cur,
hostname,
description_val,
{"summary": summary_payload},
created_ts,
)
conn.commit()
conn.close()
devices = _fetch_devices(hostname=hostname)
if not devices:
raise RuntimeError("failed to load device after upsert")
return devices[0]
def _delete_remote_device(connection_type: str, hostname: str) -> None:
conn = _db_conn()
cur = conn.cursor()
existing = _load_device_snapshot(cur, hostname=hostname)
existing_type = (existing or {}).get("summary", {}).get("connection_type") or ""
if (existing_type or "").strip().lower() != connection_type.lower():
conn.close()
raise ValueError("device not found")
cur.execute("DELETE FROM device_sites WHERE device_hostname = ?", (hostname,))
cur.execute(f"DELETE FROM {DEVICE_TABLE} WHERE hostname = ?", (hostname,))
conn.commit()
conn.close()
def _remote_devices_collection(connection_type: str):
chk = _require_admin()
if chk:
return chk
if request.method == "GET":
try:
devices = _fetch_devices(connection_type="ssh")
devices = _fetch_devices(connection_type=connection_type)
return jsonify({"devices": devices})
except RuntimeError as exc:
return jsonify({"error": str(exc)}), 500
data = request.get_json(silent=True) or {}
hostname = _clean_device_str(data.get("hostname")) or ""
address = _clean_device_str(data.get("address") or data.get("connection_endpoint") or data.get("endpoint")) or ""
address = _clean_device_str(
data.get("address")
or data.get("connection_endpoint")
or data.get("endpoint")
or data.get("host")
) or ""
description = _clean_device_str(data.get("description")) or ""
os_hint = _clean_device_str(data.get("operating_system") or data.get("os")) or ""
if not hostname:
@@ -5082,50 +5173,23 @@ def api_ssh_devices():
if not address:
return jsonify({"error": "address is required"}), 400
now_ts = _now_ts()
conn = None
try:
conn = _db_conn()
cur = conn.cursor()
existing = _load_device_snapshot(cur, hostname=hostname)
if existing and (existing.get("summary", {}).get("connection_type") or "").lower() not in ("", "ssh"):
conn.close()
return jsonify({"error": "Device already exists and is managed by an agent"}), 409
summary_payload = {
"connection_type": "ssh",
"connection_endpoint": address,
"internal_ip": address,
"external_ip": address,
"device_type": "SSH Remote",
"operating_system": os_hint or (existing.get("summary", {}).get("operating_system") if existing else ""),
"last_seen": 0,
}
_device_upsert(
cur,
device = _upsert_remote_device(
connection_type,
hostname,
address,
description,
{"summary": summary_payload},
now_ts,
os_hint,
ensure_existing_type=None,
)
conn.commit()
conn.close()
except ValueError as exc:
return jsonify({"error": str(exc)}), 409
except Exception as exc:
if conn:
conn.close()
return jsonify({"error": str(exc)}), 500
try:
devices = _fetch_devices(hostname=hostname)
except RuntimeError as exc:
return jsonify({"error": str(exc)}), 500
device = devices[0] if devices else None
return jsonify({"device": device}), 201
@app.route("/api/ssh_devices/<hostname>", methods=["PUT", "DELETE"])
def api_ssh_device_detail(hostname: str):
def _remote_device_detail(connection_type: str, hostname: str):
chk = _require_admin()
if chk:
return chk
@@ -5133,71 +5197,71 @@ def api_ssh_device_detail(hostname: str):
if not normalized_host:
return jsonify({"error": "invalid hostname"}), 400
conn = None
try:
conn = _db_conn()
cur = conn.cursor()
existing = _load_device_snapshot(cur, hostname=normalized_host)
if not existing:
conn.close()
return jsonify({"error": "device not found"}), 404
existing_type = (existing.get("summary", {}).get("connection_type") or "").lower()
if existing_type != "ssh":
conn.close()
return jsonify({"error": "device is not managed as SSH"}), 400
if request.method == "DELETE":
cur.execute("DELETE FROM device_sites WHERE device_hostname = ?", (normalized_host,))
cur.execute(f"DELETE FROM {DEVICE_TABLE} WHERE hostname = ?", (normalized_host,))
conn.commit()
conn.close()
try:
_delete_remote_device(connection_type, normalized_host)
except ValueError as exc:
return jsonify({"error": str(exc)}), 404
except Exception as exc:
return jsonify({"error": str(exc)}), 500
return jsonify({"status": "ok"})
data = request.get_json(silent=True) or {}
new_address = _clean_device_str(data.get("address") or data.get("connection_endpoint") or data.get("endpoint"))
new_description = data.get("description")
new_os = _clean_device_str(data.get("operating_system") or data.get("os"))
summary = existing.get("summary", {})
description_value = summary.get("description") or existing.get("description") or ""
if new_description is not None:
try:
description_value = str(new_description).strip()
except Exception:
description_value = summary.get("description") or ""
endpoint_value = new_address or summary.get("connection_endpoint") or ""
os_value = new_os or summary.get("operating_system") or ""
summary_payload = {
"connection_type": "ssh",
"connection_endpoint": endpoint_value,
"internal_ip": endpoint_value or summary.get("internal_ip") or "",
"external_ip": endpoint_value or summary.get("external_ip") or "",
"device_type": summary.get("device_type") or "SSH Remote",
"operating_system": os_value,
"last_seen": 0,
}
created_ts = summary.get("created_at") or existing.get("created_at") or _now_ts()
_device_upsert(
cur,
normalized_host,
description_value,
{"summary": summary_payload},
created_ts,
address = _clean_device_str(
data.get("address")
or data.get("connection_endpoint")
or data.get("endpoint")
)
conn.commit()
conn.close()
except Exception as exc:
if conn:
conn.close()
return jsonify({"error": str(exc)}), 500
description = data.get("description")
os_hint = _clean_device_str(data.get("operating_system") or data.get("os"))
if address is None and description is None and os_hint is None:
return jsonify({"error": "no fields to update"}), 400
try:
devices = _fetch_devices(hostname=normalized_host)
device = _upsert_remote_device(
connection_type,
normalized_host,
address if address is not None else "",
description,
os_hint,
ensure_existing_type=connection_type,
)
except ValueError as exc:
return jsonify({"error": str(exc)}), 404
except Exception as exc:
return jsonify({"error": str(exc)}), 500
return jsonify({"device": device})
@app.route("/api/ssh_devices", methods=["GET", "POST"])
def api_ssh_devices():
return _remote_devices_collection("ssh")
@app.route("/api/ssh_devices/<hostname>", methods=["PUT", "DELETE"])
def api_ssh_device_detail(hostname: str):
return _remote_device_detail("ssh", hostname)
@app.route("/api/winrm_devices", methods=["GET", "POST"])
def api_winrm_devices():
return _remote_devices_collection("winrm")
@app.route("/api/winrm_devices/<hostname>", methods=["PUT", "DELETE"])
def api_winrm_device_detail(hostname: str):
return _remote_device_detail("winrm", hostname)
@app.route("/api/agent_devices", methods=["GET"])
def api_agent_devices():
chk = _require_admin()
if chk:
return chk
try:
devices = _fetch_devices(only_agents=True)
return jsonify({"devices": devices})
except RuntimeError as exc:
return jsonify({"error": str(exc)}), 500
device = devices[0] if devices else None
return jsonify({"device": device})
# Endpoint: /api/devices/<guid> — methods GET.
@@ -5483,6 +5547,25 @@ def _persist_last_seen(hostname: str, last_seen: int, agent_id: str = None):
print(f"[WARN] Failed to persist last_seen for {hostname}: {e}")
def _normalize_guid(value: Optional[str]) -> str:
candidate = (value or "").strip()
if not candidate:
return ""
candidate = candidate.replace("{", "").replace("}", "")
try:
upper = candidate.upper()
if upper.count("-") == 4 and len(upper) == 36:
return upper
if len(candidate) == 32 and all(c in "0123456789abcdefABCDEF" for c in candidate):
grouped = "-".join(
[candidate[0:8], candidate[8:12], candidate[12:16], candidate[16:20], candidate[20:32]]
)
return grouped.upper()
except Exception:
pass
return candidate.upper()
def load_agents_from_db():
"""Populate registered_agents with any devices stored in the database."""
try:
@@ -5587,16 +5670,6 @@ def _device_rows_for_agent(cur, agent_id: str) -> List[Dict[str, Any]]:
return results
def _normalize_guid(value: Optional[str]) -> str:
candidate = (value or "").strip()
if not candidate:
return ""
try:
return str(uuid.UUID(candidate))
except Exception:
return candidate
def _ensure_agent_guid_for_hostname(cur, hostname: str, agent_id: Optional[str] = None) -> Optional[str]:
normalized_host = (hostname or "").strip()
if not normalized_host: