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

@@ -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",
{
method: isEdit ? "PUT" : "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
}
);
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>
);