mirror of
https://github.com/bunny-lab-io/Borealis.git
synced 2025-10-26 15:21:57 -06:00
Additional Changes to Ansible Logic
This commit is contained in:
16
Assemblies/Ansible_Playbooks/Examples/Query_OS_String.json
Normal file
16
Assemblies/Ansible_Playbooks/Examples/Query_OS_String.json
Normal 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"
|
||||||
|
}
|
||||||
@@ -35,7 +35,9 @@ import Login from "./Login.jsx";
|
|||||||
import SiteList from "./Sites/Site_List";
|
import SiteList from "./Sites/Site_List";
|
||||||
import DeviceList from "./Devices/Device_List";
|
import DeviceList from "./Devices/Device_List";
|
||||||
import DeviceDetails from "./Devices/Device_Details";
|
import DeviceDetails from "./Devices/Device_Details";
|
||||||
|
import AgentDevices from "./Devices/Agent_Devices.jsx";
|
||||||
import SSHDevices from "./Devices/SSH_Devices.jsx";
|
import SSHDevices from "./Devices/SSH_Devices.jsx";
|
||||||
|
import WinRMDevices from "./Devices/WinRM_Devices.jsx";
|
||||||
import AssemblyList from "./Assemblies/Assembly_List";
|
import AssemblyList from "./Assemblies/Assembly_List";
|
||||||
import AssemblyEditor from "./Assemblies/Assembly_Editor";
|
import AssemblyEditor from "./Assemblies/Assembly_Editor";
|
||||||
import ScheduledJobsList from "./Scheduling/Scheduled_Jobs_List";
|
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" });
|
items.push({ label: "Site List", page: "sites" });
|
||||||
break;
|
break;
|
||||||
case "devices":
|
case "devices":
|
||||||
|
items.push({ label: "Inventory", page: "devices" });
|
||||||
items.push({ label: "Devices", page: "devices" });
|
items.push({ label: "Devices", page: "devices" });
|
||||||
items.push({ label: "Device List", page: "devices" });
|
|
||||||
break;
|
break;
|
||||||
case "device_details":
|
case "device_details":
|
||||||
items.push({ label: "Devices", page: "devices" });
|
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: "Automation", page: "jobs" });
|
||||||
items.push({ label: "Community Content", page: "community" });
|
items.push({ label: "Community Content", page: "community" });
|
||||||
break;
|
break;
|
||||||
case "devices":
|
case "agent_devices":
|
||||||
items.push({ label: "Inventory", page: "devices" });
|
items.push({ label: "Inventory", page: "devices" });
|
||||||
items.push({ label: "Devices", page: "devices" });
|
items.push({ label: "Devices", page: "devices" });
|
||||||
|
items.push({ label: "Agent Devices", page: "agent_devices" });
|
||||||
break;
|
break;
|
||||||
case "ssh_devices":
|
case "ssh_devices":
|
||||||
items.push({ label: "Inventory", page: "devices" });
|
items.push({ label: "Inventory", page: "devices" });
|
||||||
|
items.push({ label: "Devices", page: "devices" });
|
||||||
items.push({ label: "SSH Devices", page: "ssh_devices" });
|
items.push({ label: "SSH Devices", page: "ssh_devices" });
|
||||||
break;
|
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":
|
case "access_credentials":
|
||||||
items.push({ label: "Access Management", page: "access_credentials" });
|
items.push({ label: "Access Management", page: "access_credentials" });
|
||||||
items.push({ label: "Credentials", 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');
|
const isAdmin = (String(userRole || '').toLowerCase() === 'admin');
|
||||||
|
|
||||||
useEffect(() => {
|
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);
|
setNotAuthorizedOpen(true);
|
||||||
setCurrentPage('devices');
|
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":
|
case "ssh_devices":
|
||||||
return <SSHDevices />;
|
return <SSHDevices />;
|
||||||
|
case "winrm_devices":
|
||||||
|
return <WinRMDevices />;
|
||||||
|
|
||||||
case "device_details":
|
case "device_details":
|
||||||
return (
|
return (
|
||||||
|
|||||||
219
Data/Server/WebUI/src/Devices/Add_Device.jsx
Normal file
219
Data/Server/WebUI/src/Devices/Add_Device.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
13
Data/Server/WebUI/src/Devices/Agent_Devices.jsx
Normal file
13
Data/Server/WebUI/src/Devices/Agent_Devices.jsx
Normal 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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -27,6 +27,7 @@ import AddIcon from "@mui/icons-material/Add";
|
|||||||
import CachedIcon from "@mui/icons-material/Cached";
|
import CachedIcon from "@mui/icons-material/Cached";
|
||||||
import { DeleteDeviceDialog, CreateCustomViewDialog, RenameCustomViewDialog } from "../Dialogs.jsx";
|
import { DeleteDeviceDialog, CreateCustomViewDialog, RenameCustomViewDialog } from "../Dialogs.jsx";
|
||||||
import QuickJob from "../Scheduling/Quick_Job.jsx";
|
import QuickJob from "../Scheduling/Quick_Job.jsx";
|
||||||
|
import AddDevice from "./Add_Device.jsx";
|
||||||
|
|
||||||
function formatLastSeen(tsSec, offlineAfter = 300) {
|
function formatLastSeen(tsSec, offlineAfter = 300) {
|
||||||
if (!tsSec) return "unknown";
|
if (!tsSec) return "unknown";
|
||||||
@@ -66,7 +67,14 @@ function formatUptime(seconds) {
|
|||||||
return parts.join(' ');
|
return parts.join(' ');
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function DeviceList({ onSelectDevice }) {
|
export default function DeviceList({
|
||||||
|
onSelectDevice,
|
||||||
|
filterMode = "all",
|
||||||
|
title,
|
||||||
|
showAddButton,
|
||||||
|
addButtonLabel,
|
||||||
|
defaultAddType,
|
||||||
|
}) {
|
||||||
const [rows, setRows] = useState([]);
|
const [rows, setRows] = useState([]);
|
||||||
const [orderBy, setOrderBy] = useState("status");
|
const [orderBy, setOrderBy] = useState("status");
|
||||||
const [order, setOrder] = useState("desc");
|
const [order, setOrder] = useState("desc");
|
||||||
@@ -76,6 +84,36 @@ export default function DeviceList({ onSelectDevice }) {
|
|||||||
// Track selection by agent id to avoid duplicate hostname collisions
|
// Track selection by agent id to avoid duplicate hostname collisions
|
||||||
const [selectedIds, setSelectedIds] = useState(() => new Set());
|
const [selectedIds, setSelectedIds] = useState(() => new Set());
|
||||||
const [quickJobOpen, setQuickJobOpen] = useState(false);
|
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)
|
// Saved custom views (from server)
|
||||||
const [views, setViews] = useState([]); // [{id, name, columns:[id], filters:{}}]
|
const [views, setViews] = useState([]); // [{id, name, columns:[id], filters:{}}]
|
||||||
@@ -305,6 +343,7 @@ export default function DeviceList({ onSelectDevice }) {
|
|||||||
0
|
0
|
||||||
) || 0;
|
) || 0;
|
||||||
const connectionType = (device.connection_type || summary.connection_type || '').trim().toLowerCase();
|
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 connectionEndpoint = (device.connection_endpoint || summary.connection_endpoint || '').trim();
|
||||||
|
|
||||||
const memoryList = Array.isArray(device.memory) ? device.memory : [];
|
const memoryList = Array.isArray(device.memory) ? device.memory : [];
|
||||||
@@ -329,7 +368,7 @@ export default function DeviceList({ onSelectDevice }) {
|
|||||||
lastSeenDisplay: formatLastSeen(lastSeen),
|
lastSeenDisplay: formatLastSeen(lastSeen),
|
||||||
os: osName,
|
os: osName,
|
||||||
lastUser,
|
lastUser,
|
||||||
type,
|
type: type || connectionLabel || '',
|
||||||
site: device.site_name || 'Not Configured',
|
site: device.site_name || 'Not Configured',
|
||||||
siteId: device.site_id || null,
|
siteId: device.site_id || null,
|
||||||
siteDescription: device.site_description || '',
|
siteDescription: device.site_description || '',
|
||||||
@@ -360,17 +399,27 @@ export default function DeviceList({ onSelectDevice }) {
|
|||||||
summary,
|
summary,
|
||||||
details: device.details || {},
|
details: device.details || {},
|
||||||
connectionType,
|
connectionType,
|
||||||
|
connectionLabel,
|
||||||
connectionEndpoint,
|
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) {
|
} catch (e) {
|
||||||
console.warn('Failed to load devices:', e);
|
console.warn('Failed to load devices:', e);
|
||||||
setRows([]);
|
setRows([]);
|
||||||
}
|
}
|
||||||
}, [repoHash, fetchLatestRepoHash, computeAgentVersion]);
|
}, [repoHash, fetchLatestRepoHash, computeAgentVersion, filterMode]);
|
||||||
|
|
||||||
const fetchViews = useCallback(async () => {
|
const fetchViews = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -653,7 +702,7 @@ export default function DeviceList({ onSelectDevice }) {
|
|||||||
<Box sx={{ p: 2, pb: 1, display: "flex", flexDirection: 'column', gap: 1 }}>
|
<Box sx={{ p: 2, pb: 1, display: "flex", flexDirection: 'column', gap: 1 }}>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||||
<Typography variant="h6" sx={{ color: "#58a6ff", mb: 0 }}>
|
<Typography variant="h6" sx={{ color: "#58a6ff", mb: 0 }}>
|
||||||
Device Inventory
|
{computedTitle}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center' }}>
|
<Box sx={{ display: 'flex', alignItems: 'center' }}>
|
||||||
{/* Views dropdown + add button */}
|
{/* Views dropdown + add button */}
|
||||||
@@ -753,6 +802,20 @@ export default function DeviceList({ onSelectDevice }) {
|
|||||||
<ViewColumnIcon fontSize="small" />
|
<ViewColumnIcon fontSize="small" />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
{derivedShowAddButton && (
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
size="small"
|
||||||
|
startIcon={<AddIcon />}
|
||||||
|
sx={{ bgcolor: "#58a6ff", color: "#0b0f19" }}
|
||||||
|
onClick={() => {
|
||||||
|
setAddDeviceType(derivedDefaultType ?? null);
|
||||||
|
setAddDeviceOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{derivedAddLabel}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
{/* Second row: Quick Job button aligned under header title */}
|
{/* Second row: Quick Job button aligned under header title */}
|
||||||
@@ -1224,6 +1287,19 @@ export default function DeviceList({ onSelectDevice }) {
|
|||||||
</Box>
|
</Box>
|
||||||
</Popover>
|
</Popover>
|
||||||
)}
|
)}
|
||||||
|
<AddDevice
|
||||||
|
open={addDeviceOpen}
|
||||||
|
defaultType={addDeviceType}
|
||||||
|
onClose={() => {
|
||||||
|
setAddDeviceOpen(false);
|
||||||
|
setAddDeviceType(derivedDefaultType ?? null);
|
||||||
|
}}
|
||||||
|
onCreated={() => {
|
||||||
|
setAddDeviceOpen(false);
|
||||||
|
setAddDeviceType(derivedDefaultType ?? null);
|
||||||
|
fetchDevices({ refreshRepo: true });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</Paper>
|
</Paper>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import EditIcon from "@mui/icons-material/Edit";
|
|||||||
import DeleteIcon from "@mui/icons-material/Delete";
|
import DeleteIcon from "@mui/icons-material/Delete";
|
||||||
import RefreshIcon from "@mui/icons-material/Refresh";
|
import RefreshIcon from "@mui/icons-material/Refresh";
|
||||||
import { ConfirmDeleteDialog } from "../Dialogs.jsx";
|
import { ConfirmDeleteDialog } from "../Dialogs.jsx";
|
||||||
|
import AddDevice from "./Add_Device.jsx";
|
||||||
|
|
||||||
const tableStyles = {
|
const tableStyles = {
|
||||||
"& th, & td": {
|
"& th, & td": {
|
||||||
@@ -45,7 +46,19 @@ const defaultForm = {
|
|||||||
operating_system: ""
|
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 [rows, setRows] = useState([]);
|
||||||
const [orderBy, setOrderBy] = useState("hostname");
|
const [orderBy, setOrderBy] = useState("hostname");
|
||||||
const [order, setOrder] = useState("asc");
|
const [order, setOrder] = useState("asc");
|
||||||
@@ -58,6 +71,7 @@ export default function SSHDevices() {
|
|||||||
const [editTarget, setEditTarget] = useState(null);
|
const [editTarget, setEditTarget] = useState(null);
|
||||||
const [deleteTarget, setDeleteTarget] = useState(null);
|
const [deleteTarget, setDeleteTarget] = useState(null);
|
||||||
const [deleteBusy, setDeleteBusy] = useState(false);
|
const [deleteBusy, setDeleteBusy] = useState(false);
|
||||||
|
const [addDeviceOpen, setAddDeviceOpen] = useState(false);
|
||||||
|
|
||||||
const isEdit = Boolean(editTarget);
|
const isEdit = Boolean(editTarget);
|
||||||
|
|
||||||
@@ -65,7 +79,7 @@ export default function SSHDevices() {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError("");
|
setError("");
|
||||||
try {
|
try {
|
||||||
const resp = await fetch("/api/ssh_devices");
|
const resp = await fetch(apiBase);
|
||||||
if (!resp.ok) {
|
if (!resp.ok) {
|
||||||
const data = await resp.json().catch(() => ({}));
|
const data = await resp.json().catch(() => ({}));
|
||||||
throw new Error(data?.error || `HTTP ${resp.status}`);
|
throw new Error(data?.error || `HTTP ${resp.status}`);
|
||||||
@@ -79,7 +93,7 @@ export default function SSHDevices() {
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, []);
|
}, [apiBase]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadDevices();
|
loadDevices();
|
||||||
@@ -119,9 +133,7 @@ export default function SSHDevices() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const openCreate = () => {
|
const openCreate = () => {
|
||||||
setEditTarget(null);
|
setAddDeviceOpen(true);
|
||||||
setForm(defaultForm);
|
|
||||||
setDialogOpen(true);
|
|
||||||
setFormError("");
|
setFormError("");
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -164,16 +176,14 @@ export default function SSHDevices() {
|
|||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
setFormError("");
|
setFormError("");
|
||||||
try {
|
try {
|
||||||
const resp = await fetch(
|
const endpoint = isEdit
|
||||||
isEdit
|
? `${apiBase}/${encodeURIComponent(editTarget.hostname)}`
|
||||||
? `/api/ssh_devices/${encodeURIComponent(editTarget.hostname)}`
|
: apiBase;
|
||||||
: "/api/ssh_devices",
|
const resp = await fetch(endpoint, {
|
||||||
{
|
method: isEdit ? "PUT" : "POST",
|
||||||
method: isEdit ? "PUT" : "POST",
|
headers: { "Content-Type": "application/json" },
|
||||||
headers: { "Content-Type": "application/json" },
|
body: JSON.stringify(payload)
|
||||||
body: JSON.stringify(payload)
|
});
|
||||||
}
|
|
||||||
);
|
|
||||||
const data = await resp.json().catch(() => ({}));
|
const data = await resp.json().catch(() => ({}));
|
||||||
if (!resp.ok) {
|
if (!resp.ok) {
|
||||||
throw new Error(data?.error || `HTTP ${resp.status}`);
|
throw new Error(data?.error || `HTTP ${resp.status}`);
|
||||||
@@ -205,7 +215,7 @@ export default function SSHDevices() {
|
|||||||
if (!deleteTarget) return;
|
if (!deleteTarget) return;
|
||||||
setDeleteBusy(true);
|
setDeleteBusy(true);
|
||||||
try {
|
try {
|
||||||
const resp = await fetch(`/api/ssh_devices/${encodeURIComponent(deleteTarget.hostname)}`, {
|
const resp = await fetch(`${apiBase}/${encodeURIComponent(deleteTarget.hostname)}`, {
|
||||||
method: "DELETE"
|
method: "DELETE"
|
||||||
});
|
});
|
||||||
const data = await resp.json().catch(() => ({}));
|
const data = await resp.json().catch(() => ({}));
|
||||||
@@ -232,10 +242,10 @@ export default function SSHDevices() {
|
|||||||
>
|
>
|
||||||
<Box>
|
<Box>
|
||||||
<Typography variant="h6" sx={{ color: "#58a6ff", mb: 0 }}>
|
<Typography variant="h6" sx={{ color: "#58a6ff", mb: 0 }}>
|
||||||
SSH Devices
|
{pageTitle}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="body2" sx={{ color: "#aaa" }}>
|
<Typography variant="body2" sx={{ color: "#aaa" }}>
|
||||||
Manage remote endpoints reachable via SSH for playbook execution.
|
{descriptionText}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
|
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
|
||||||
@@ -256,7 +266,7 @@ export default function SSHDevices() {
|
|||||||
sx={{ bgcolor: "#58a6ff", color: "#0b0f19" }}
|
sx={{ bgcolor: "#58a6ff", color: "#0b0f19" }}
|
||||||
onClick={openCreate}
|
onClick={openCreate}
|
||||||
>
|
>
|
||||||
New SSH Device
|
{addButtonLabel}
|
||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -269,7 +279,7 @@ export default function SSHDevices() {
|
|||||||
{loading && (
|
{loading && (
|
||||||
<Box sx={{ px: 2, py: 1.5, display: "flex", alignItems: "center", gap: 1, color: "#7db7ff" }}>
|
<Box sx={{ px: 2, py: 1.5, display: "flex", alignItems: "center", gap: 1, color: "#7db7ff" }}>
|
||||||
<CircularProgress size={18} sx={{ color: "#58a6ff" }} />
|
<CircularProgress size={18} sx={{ color: "#58a6ff" }} />
|
||||||
<Typography variant="body2">Loading SSH devices…</Typography>
|
<Typography variant="body2">{loadingLabel}</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -291,7 +301,7 @@ export default function SSHDevices() {
|
|||||||
direction={orderBy === "address" ? order : "asc"}
|
direction={orderBy === "address" ? order : "asc"}
|
||||||
onClick={handleSort("address")}
|
onClick={handleSort("address")}
|
||||||
>
|
>
|
||||||
SSH Address
|
{addressLabel}
|
||||||
</TableSortLabel>
|
</TableSortLabel>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell sortDirection={orderBy === "description" ? order : false}>
|
<TableCell sortDirection={orderBy === "description" ? order : false}>
|
||||||
@@ -341,7 +351,7 @@ export default function SSHDevices() {
|
|||||||
{!sortedRows.length && !loading && (
|
{!sortedRows.length && !loading && (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={5} sx={{ textAlign: "center", color: "#888" }}>
|
<TableCell colSpan={5} sx={{ textAlign: "center", color: "#888" }}>
|
||||||
No SSH devices have been added yet.
|
{emptyLabel}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
)}
|
)}
|
||||||
@@ -355,7 +365,7 @@ export default function SSHDevices() {
|
|||||||
maxWidth="sm"
|
maxWidth="sm"
|
||||||
PaperProps={{ sx: { bgcolor: "#121212", color: "#fff" } }}
|
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 }}>
|
<DialogContent sx={{ display: "flex", flexDirection: "column", gap: 2, mt: 1 }}>
|
||||||
<TextField
|
<TextField
|
||||||
label="Hostname"
|
label="Hostname"
|
||||||
@@ -376,7 +386,7 @@ export default function SSHDevices() {
|
|||||||
helperText="Hostname used within Borealis (unique)."
|
helperText="Hostname used within Borealis (unique)."
|
||||||
/>
|
/>
|
||||||
<TextField
|
<TextField
|
||||||
label="SSH Address"
|
label={addressLabel}
|
||||||
value={form.address}
|
value={form.address}
|
||||||
onChange={(e) => setForm((prev) => ({ ...prev, address: e.target.value }))}
|
onChange={(e) => setForm((prev) => ({ ...prev, address: e.target.value }))}
|
||||||
fullWidth
|
fullWidth
|
||||||
@@ -390,7 +400,7 @@ export default function SSHDevices() {
|
|||||||
},
|
},
|
||||||
"& .MuiInputLabel-root": { color: "#aaa" }
|
"& .MuiInputLabel-root": { color: "#aaa" }
|
||||||
}}
|
}}
|
||||||
helperText="IP or FQDN Borealis can reach over SSH."
|
helperText={`IP or FQDN Borealis can reach over ${typeLabel}.`}
|
||||||
/>
|
/>
|
||||||
<TextField
|
<TextField
|
||||||
label="Description"
|
label="Description"
|
||||||
@@ -449,13 +459,22 @@ export default function SSHDevices() {
|
|||||||
open={Boolean(deleteTarget)}
|
open={Boolean(deleteTarget)}
|
||||||
message={
|
message={
|
||||||
deleteTarget
|
deleteTarget
|
||||||
? `Remove SSH device '${deleteTarget.hostname}' from inventory?`
|
? `Remove ${typeLabel} device '${deleteTarget.hostname}' from inventory?`
|
||||||
: ""
|
: ""
|
||||||
}
|
}
|
||||||
onCancel={() => setDeleteTarget(null)}
|
onCancel={() => setDeleteTarget(null)}
|
||||||
onConfirm={handleDelete}
|
onConfirm={handleDelete}
|
||||||
confirmDisabled={deleteBusy}
|
confirmDisabled={deleteBusy}
|
||||||
/>
|
/>
|
||||||
|
<AddDevice
|
||||||
|
open={addDeviceOpen}
|
||||||
|
defaultType={type}
|
||||||
|
onClose={() => setAddDeviceOpen(false)}
|
||||||
|
onCreated={() => {
|
||||||
|
setAddDeviceOpen(false);
|
||||||
|
loadDevices();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</Paper>
|
</Paper>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
6
Data/Server/WebUI/src/Devices/WinRM_Devices.jsx
Normal file
6
Data/Server/WebUI/src/Devices/WinRM_Devices.jsx
Normal 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" />;
|
||||||
|
}
|
||||||
@@ -154,7 +154,7 @@ function NavigationSidebar({ currentPage, onNavigate, isAdmin = false }) {
|
|||||||
})()}
|
})()}
|
||||||
{/* Inventory */}
|
{/* Inventory */}
|
||||||
{(() => {
|
{(() => {
|
||||||
const groupActive = currentPage === "devices" || currentPage === "ssh_devices";
|
const groupActive = ["devices", "ssh_devices", "winrm_devices", "agent_devices"].includes(currentPage);
|
||||||
return (
|
return (
|
||||||
<Accordion
|
<Accordion
|
||||||
expanded={expandedNav.devices}
|
expanded={expandedNav.devices}
|
||||||
@@ -192,7 +192,9 @@ function NavigationSidebar({ currentPage, onNavigate, isAdmin = false }) {
|
|||||||
</AccordionSummary>
|
</AccordionSummary>
|
||||||
<AccordionDetails sx={{ p: 0, bgcolor: "#232323" }}>
|
<AccordionDetails sx={{ p: 0, bgcolor: "#232323" }}>
|
||||||
<NavItem icon={<DevicesIcon fontSize="small" />} label="Devices" pageKey="devices" />
|
<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="SSH Devices" pageKey="ssh_devices" indent />
|
||||||
|
<NavItem icon={<DevicesIcon fontSize="small" />} label="WinRM Devices" pageKey="winrm_devices" indent />
|
||||||
</AccordionDetails>
|
</AccordionDetails>
|
||||||
</Accordion>
|
</Accordion>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -19,8 +19,10 @@ Section Guide:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import eventlet
|
import eventlet
|
||||||
# Monkey-patch stdlib for cooperative sockets
|
# Monkey-patch stdlib for cooperative sockets (keep real threads for tpool usage)
|
||||||
eventlet.monkey_patch()
|
eventlet.monkey_patch(thread=False)
|
||||||
|
|
||||||
|
from eventlet import tpool
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
import re
|
import re
|
||||||
@@ -43,6 +45,7 @@ import subprocess
|
|||||||
import stat
|
import stat
|
||||||
import traceback
|
import traceback
|
||||||
from threading import Lock
|
from threading import Lock
|
||||||
|
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -2045,7 +2048,7 @@ def _queue_server_ansible_run(
|
|||||||
"started_ts": _now_ts(),
|
"started_ts": _now_ts(),
|
||||||
}
|
}
|
||||||
try:
|
try:
|
||||||
socketio.start_background_task(_execute_server_ansible_run, ctx)
|
socketio.start_background_task(_execute_server_ansible_run, ctx, None)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
_ansible_log_server(f"[server_run] failed to queue background task run_id={run_id}: {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))
|
_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}"
|
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,
|
cmd,
|
||||||
stdout=subprocess.PIPE,
|
stdout=subprocess.PIPE,
|
||||||
stderr=subprocess.PIPE,
|
stderr=subprocess.PIPE,
|
||||||
@@ -4967,6 +4971,7 @@ def _fetch_devices(
|
|||||||
*,
|
*,
|
||||||
connection_type: Optional[str] = None,
|
connection_type: Optional[str] = None,
|
||||||
hostname: Optional[str] = None,
|
hostname: Optional[str] = None,
|
||||||
|
only_agents: bool = False,
|
||||||
) -> List[Dict[str, Any]]:
|
) -> List[Dict[str, Any]]:
|
||||||
try:
|
try:
|
||||||
conn = _db_conn()
|
conn = _db_conn()
|
||||||
@@ -4986,6 +4991,8 @@ def _fetch_devices(
|
|||||||
if hostname:
|
if hostname:
|
||||||
clauses.append("LOWER(d.hostname) = LOWER(?)")
|
clauses.append("LOWER(d.hostname) = LOWER(?)")
|
||||||
params.append(hostname.lower())
|
params.append(hostname.lower())
|
||||||
|
if only_agents:
|
||||||
|
clauses.append("(d.connection_type IS NULL OR TRIM(d.connection_type) = '')")
|
||||||
if clauses:
|
if clauses:
|
||||||
sql += " WHERE " + " AND ".join(clauses)
|
sql += " WHERE " + " AND ".join(clauses)
|
||||||
cur.execute(sql, params)
|
cur.execute(sql, params)
|
||||||
@@ -5060,21 +5067,105 @@ def list_devices():
|
|||||||
return jsonify({"devices": devices})
|
return jsonify({"devices": devices})
|
||||||
|
|
||||||
|
|
||||||
@app.route("/api/ssh_devices", methods=["GET", "POST"])
|
def _upsert_remote_device(
|
||||||
def api_ssh_devices():
|
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()
|
chk = _require_admin()
|
||||||
if chk:
|
if chk:
|
||||||
return chk
|
return chk
|
||||||
if request.method == "GET":
|
if request.method == "GET":
|
||||||
try:
|
try:
|
||||||
devices = _fetch_devices(connection_type="ssh")
|
devices = _fetch_devices(connection_type=connection_type)
|
||||||
return jsonify({"devices": devices})
|
return jsonify({"devices": devices})
|
||||||
except RuntimeError as exc:
|
except RuntimeError as exc:
|
||||||
return jsonify({"error": str(exc)}), 500
|
return jsonify({"error": str(exc)}), 500
|
||||||
|
|
||||||
data = request.get_json(silent=True) or {}
|
data = request.get_json(silent=True) or {}
|
||||||
hostname = _clean_device_str(data.get("hostname")) 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 ""
|
description = _clean_device_str(data.get("description")) or ""
|
||||||
os_hint = _clean_device_str(data.get("operating_system") or data.get("os")) or ""
|
os_hint = _clean_device_str(data.get("operating_system") or data.get("os")) or ""
|
||||||
if not hostname:
|
if not hostname:
|
||||||
@@ -5082,50 +5173,23 @@ def api_ssh_devices():
|
|||||||
if not address:
|
if not address:
|
||||||
return jsonify({"error": "address is required"}), 400
|
return jsonify({"error": "address is required"}), 400
|
||||||
|
|
||||||
now_ts = _now_ts()
|
|
||||||
conn = None
|
|
||||||
try:
|
try:
|
||||||
conn = _db_conn()
|
device = _upsert_remote_device(
|
||||||
cur = conn.cursor()
|
connection_type,
|
||||||
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,
|
|
||||||
hostname,
|
hostname,
|
||||||
|
address,
|
||||||
description,
|
description,
|
||||||
{"summary": summary_payload},
|
os_hint,
|
||||||
now_ts,
|
ensure_existing_type=None,
|
||||||
)
|
)
|
||||||
conn.commit()
|
except ValueError as exc:
|
||||||
conn.close()
|
return jsonify({"error": str(exc)}), 409
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
if conn:
|
|
||||||
conn.close()
|
|
||||||
return jsonify({"error": str(exc)}), 500
|
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
|
return jsonify({"device": device}), 201
|
||||||
|
|
||||||
|
|
||||||
@app.route("/api/ssh_devices/<hostname>", methods=["PUT", "DELETE"])
|
def _remote_device_detail(connection_type: str, hostname: str):
|
||||||
def api_ssh_device_detail(hostname: str):
|
|
||||||
chk = _require_admin()
|
chk = _require_admin()
|
||||||
if chk:
|
if chk:
|
||||||
return chk
|
return chk
|
||||||
@@ -5133,71 +5197,71 @@ def api_ssh_device_detail(hostname: str):
|
|||||||
if not normalized_host:
|
if not normalized_host:
|
||||||
return jsonify({"error": "invalid hostname"}), 400
|
return jsonify({"error": "invalid hostname"}), 400
|
||||||
|
|
||||||
conn = None
|
if request.method == "DELETE":
|
||||||
|
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 {}
|
||||||
|
address = _clean_device_str(
|
||||||
|
data.get("address")
|
||||||
|
or data.get("connection_endpoint")
|
||||||
|
or data.get("endpoint")
|
||||||
|
)
|
||||||
|
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:
|
try:
|
||||||
conn = _db_conn()
|
device = _upsert_remote_device(
|
||||||
cur = conn.cursor()
|
connection_type,
|
||||||
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()
|
|
||||||
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,
|
normalized_host,
|
||||||
description_value,
|
address if address is not None else "",
|
||||||
{"summary": summary_payload},
|
description,
|
||||||
created_ts,
|
os_hint,
|
||||||
|
ensure_existing_type=connection_type,
|
||||||
)
|
)
|
||||||
conn.commit()
|
except ValueError as exc:
|
||||||
conn.close()
|
return jsonify({"error": str(exc)}), 404
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
if conn:
|
|
||||||
conn.close()
|
|
||||||
return jsonify({"error": str(exc)}), 500
|
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:
|
try:
|
||||||
devices = _fetch_devices(hostname=normalized_host)
|
devices = _fetch_devices(only_agents=True)
|
||||||
|
return jsonify({"devices": devices})
|
||||||
except RuntimeError as exc:
|
except RuntimeError as exc:
|
||||||
return jsonify({"error": str(exc)}), 500
|
return jsonify({"error": str(exc)}), 500
|
||||||
device = devices[0] if devices else None
|
|
||||||
return jsonify({"device": device})
|
|
||||||
|
|
||||||
|
|
||||||
# Endpoint: /api/devices/<guid> — methods GET.
|
# 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}")
|
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():
|
def load_agents_from_db():
|
||||||
"""Populate registered_agents with any devices stored in the database."""
|
"""Populate registered_agents with any devices stored in the database."""
|
||||||
try:
|
try:
|
||||||
@@ -5587,16 +5670,6 @@ def _device_rows_for_agent(cur, agent_id: str) -> List[Dict[str, Any]]:
|
|||||||
return results
|
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]:
|
def _ensure_agent_guid_for_hostname(cur, hostname: str, agent_id: Optional[str] = None) -> Optional[str]:
|
||||||
normalized_host = (hostname or "").strip()
|
normalized_host = (hostname or "").strip()
|
||||||
if not normalized_host:
|
if not normalized_host:
|
||||||
|
|||||||
Reference in New Issue
Block a user