Additional SSH Host Implementation

This commit is contained in:
2025-10-11 04:05:22 -06:00
parent 01202e8ac2
commit c0f47075d6
6 changed files with 818 additions and 20 deletions

View File

@@ -35,6 +35,7 @@ import Login from "./Login.jsx";
import SiteList from "./Sites/Site_List";
import DeviceList from "./Devices/Device_List";
import DeviceDetails from "./Devices/Device_Details";
import SSHDevices from "./Devices/SSH_Devices.jsx";
import AssemblyList from "./Assemblies/Assembly_List";
import AssemblyEditor from "./Assemblies/Assembly_Editor";
import ScheduledJobsList from "./Scheduling/Scheduled_Jobs_List";
@@ -202,6 +203,14 @@ const LOCAL_STORAGE_KEY = "borealis_persistent_state";
items.push({ label: "Automation", page: "jobs" });
items.push({ label: "Community Content", page: "community" });
break;
case "devices":
items.push({ label: "Inventory", page: "devices" });
items.push({ label: "Devices", page: "devices" });
break;
case "ssh_devices":
items.push({ label: "Inventory", page: "devices" });
items.push({ label: "SSH Devices", page: "ssh_devices" });
break;
case "access_credentials":
items.push({ label: "Access Management", page: "access_credentials" });
items.push({ label: "Credentials", page: "access_credentials" });
@@ -556,7 +565,7 @@ 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')) {
if (!isAdmin && (currentPage === 'server_info' || currentPage === 'access_credentials' || currentPage === 'access_users' || currentPage === 'ssh_devices')) {
setNotAuthorizedOpen(true);
setCurrentPage('devices');
}
@@ -584,6 +593,8 @@ const LOCAL_STORAGE_KEY = "borealis_persistent_state";
}}
/>
);
case "ssh_devices":
return <SSHDevices />;
case "device_details":
return (

View File

@@ -48,6 +48,12 @@ export default function DeviceDetails({ device, onBack }) {
const [softwareOrder, setSoftwareOrder] = useState("asc");
const [softwareSearch, setSoftwareSearch] = useState("");
const [description, setDescription] = useState("");
const [connectionType, setConnectionType] = useState("");
const [connectionEndpoint, setConnectionEndpoint] = useState("");
const [connectionDraft, setConnectionDraft] = useState("");
const [connectionSaving, setConnectionSaving] = useState(false);
const [connectionMessage, setConnectionMessage] = useState("");
const [connectionError, setConnectionError] = useState("");
const [historyRows, setHistoryRows] = useState([]);
const [historyOrderBy, setHistoryOrderBy] = useState("ran_at");
const [historyOrder, setHistoryOrder] = useState("desc");
@@ -70,6 +76,17 @@ export default function DeviceDetails({ device, onBack }) {
return now - tsSec <= 300 ? "Online" : "Offline";
});
useEffect(() => {
setConnectionError("");
}, [connectionDraft]);
useEffect(() => {
if (connectionType !== "ssh") {
setConnectionMessage("");
setConnectionError("");
}
}, [connectionType]);
useEffect(() => {
let canceled = false;
const loadAssemblyNames = async () => {
@@ -222,6 +239,21 @@ export default function DeviceDetails({ device, onBack }) {
normalizedSummary.description = detailData.description;
}
const connectionTypeValue =
(normalizedSummary.connection_type ||
normalizedSummary.remote_type ||
"").toLowerCase();
const connectionEndpointValue =
normalizedSummary.connection_endpoint ||
normalizedSummary.connection_address ||
detailData?.connection_endpoint ||
"";
setConnectionType(connectionTypeValue);
setConnectionEndpoint(connectionEndpointValue);
setConnectionDraft(connectionEndpointValue);
setConnectionMessage("");
setConnectionError("");
const normalized = {
summary: normalizedSummary,
memory: Array.isArray(detailData?.memory)
@@ -282,6 +314,8 @@ export default function DeviceDetails({ device, onBack }) {
siteName: detailData?.site_name || "",
siteDescription: detailData?.site_description || "",
status: detailData?.status || "",
connectionType: connectionTypeValue,
connectionEndpoint: connectionEndpointValue,
};
setMeta(metaPayload);
setDescription(normalizedSummary.description || detailData?.description || "");
@@ -313,6 +347,43 @@ export default function DeviceDetails({ device, onBack }) {
return (meta?.hostname || agent?.hostname || device?.hostname || "").trim();
}, [meta?.hostname, agent?.hostname, device?.hostname]);
const saveConnectionEndpoint = useCallback(async () => {
if (connectionType !== "ssh") return;
const host = activityHostname;
if (!host) return;
const trimmed = connectionDraft.trim();
if (!trimmed) {
setConnectionError("Address is required.");
return;
}
if (trimmed === connectionEndpoint.trim()) {
setConnectionMessage("No changes to save.");
return;
}
setConnectionSaving(true);
setConnectionError("");
setConnectionMessage("");
try {
const resp = await fetch(`/api/ssh_devices/${encodeURIComponent(host)}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ address: trimmed })
});
const data = await resp.json().catch(() => ({}));
if (!resp.ok) throw new Error(data?.error || `HTTP ${resp.status}`);
const updated = data?.device?.connection_endpoint || trimmed;
setConnectionEndpoint(updated);
setConnectionDraft(updated);
setMeta((prev) => ({ ...(prev || {}), connectionEndpoint: updated }));
setConnectionMessage("SSH endpoint updated.");
setTimeout(() => setConnectionMessage(""), 3000);
} catch (err) {
setConnectionError(String(err.message || err));
} finally {
setConnectionSaving(false);
}
}, [connectionType, connectionDraft, connectionEndpoint, activityHostname]);
const loadHistory = useCallback(async () => {
if (!activityHostname) return;
try {
@@ -689,6 +760,44 @@ export default function DeviceDetails({ device, onBack }) {
/>
</TableCell>
</TableRow>
{connectionType === "ssh" && (
<TableRow>
<TableCell sx={{ fontWeight: 500 }}>SSH Endpoint</TableCell>
<TableCell>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
<TextField
size="small"
value={connectionDraft}
onChange={(e) => setConnectionDraft(e.target.value)}
placeholder="user@host or host"
sx={{
maxWidth: 300,
input: { color: '#fff' },
'& .MuiOutlinedInput-root': {
'& fieldset': { borderColor: '#555' },
'&:hover fieldset': { borderColor: '#888' }
}
}}
/>
<Button
size="small"
variant="outlined"
sx={{ color: '#58a6ff', borderColor: '#58a6ff' }}
onClick={saveConnectionEndpoint}
disabled={connectionSaving || connectionDraft.trim() === connectionEndpoint.trim()}
>
{connectionSaving ? "Saving..." : "Save"}
</Button>
</Box>
{connectionMessage && (
<Typography variant="caption" sx={{ color: '#7db7ff' }}>{connectionMessage}</Typography>
)}
{connectionError && (
<Typography variant="caption" sx={{ color: '#ff8080' }}>{connectionError}</Typography>
)}
</TableCell>
</TableRow>
)}
{summaryItems.map((item) => (
<TableRow key={item.label}>
<TableCell sx={{ fontWeight: 500 }}>{item.label}</TableCell>

View File

@@ -304,6 +304,8 @@ export default function DeviceList({ onSelectDevice }) {
summary.uptime ||
0
) || 0;
const connectionType = (device.connection_type || summary.connection_type || '').trim().toLowerCase();
const connectionEndpoint = (device.connection_endpoint || summary.connection_endpoint || '').trim();
const memoryList = Array.isArray(device.memory) ? device.memory : [];
const networkList = Array.isArray(device.network) ? device.network : [];
@@ -357,6 +359,9 @@ export default function DeviceList({ onSelectDevice }) {
cpuRaw: normalizeJson(cpuObj),
summary,
details: device.details || {},
connectionType,
connectionEndpoint,
isRemote: connectionType === 'ssh',
};
});
@@ -648,7 +653,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 }}>
Devices
Device Inventory
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center' }}>
{/* Views dropdown + add button */}
@@ -856,7 +861,27 @@ export default function DeviceList({ onSelectDevice }) {
},
}}
>
{r.hostname}
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
{r.isRemote && (
<Box
component="span"
sx={{
display: "inline-flex",
alignItems: "center",
px: 0.75,
py: 0.1,
borderRadius: 999,
bgcolor: "#2a3b28",
color: "#7cffc4",
fontSize: "11px",
fontWeight: 600
}}
>
SSH
</Box>
)}
<span>{r.hostname}</span>
</Box>
</TableCell>
);
case "description":

View File

@@ -0,0 +1,461 @@
import React, { useCallback, useEffect, useMemo, useState } from "react";
import {
Paper,
Box,
Typography,
Button,
IconButton,
Table,
TableHead,
TableBody,
TableRow,
TableCell,
TableSortLabel,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
TextField,
CircularProgress
} from "@mui/material";
import AddIcon from "@mui/icons-material/Add";
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";
const tableStyles = {
"& th, & td": {
color: "#ddd",
borderColor: "#2a2a2a",
fontSize: 13,
py: 0.75
},
"& th": {
fontWeight: 600
},
"& th .MuiTableSortLabel-root": { color: "#ddd" },
"& th .MuiTableSortLabel-root.Mui-active": { color: "#ddd" }
};
const defaultForm = {
hostname: "",
address: "",
description: "",
operating_system: ""
};
export default function SSHDevices() {
const [rows, setRows] = useState([]);
const [orderBy, setOrderBy] = useState("hostname");
const [order, setOrder] = useState("asc");
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [dialogOpen, setDialogOpen] = useState(false);
const [form, setForm] = useState(defaultForm);
const [formError, setFormError] = useState("");
const [submitting, setSubmitting] = useState(false);
const [editTarget, setEditTarget] = useState(null);
const [deleteTarget, setDeleteTarget] = useState(null);
const [deleteBusy, setDeleteBusy] = useState(false);
const isEdit = Boolean(editTarget);
const loadDevices = useCallback(async () => {
setLoading(true);
setError("");
try {
const resp = await fetch("/api/ssh_devices");
if (!resp.ok) {
const data = await resp.json().catch(() => ({}));
throw new Error(data?.error || `HTTP ${resp.status}`);
}
const data = await resp.json();
const list = Array.isArray(data?.devices) ? data.devices : [];
setRows(list);
} catch (err) {
setError(String(err.message || err));
setRows([]);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
loadDevices();
}, [loadDevices]);
const sortedRows = useMemo(() => {
const list = [...rows];
list.sort((a, b) => {
const getKey = (row) => {
switch (orderBy) {
case "created_at":
return Number(row.created_at || 0);
case "address":
return (row.connection_endpoint || "").toLowerCase();
case "description":
return (row.description || "").toLowerCase();
default:
return (row.hostname || "").toLowerCase();
}
};
const aKey = getKey(a);
const bKey = getKey(b);
if (aKey < bKey) return order === "asc" ? -1 : 1;
if (aKey > bKey) return order === "asc" ? 1 : -1;
return 0;
});
return list;
}, [rows, order, orderBy]);
const handleSort = (column) => () => {
if (orderBy === column) {
setOrder((prev) => (prev === "asc" ? "desc" : "asc"));
} else {
setOrderBy(column);
setOrder("asc");
}
};
const openCreate = () => {
setEditTarget(null);
setForm(defaultForm);
setDialogOpen(true);
setFormError("");
};
const openEdit = (row) => {
setEditTarget(row);
setForm({
hostname: row.hostname || "",
address: row.connection_endpoint || "",
description: row.description || "",
operating_system: row.summary?.operating_system || ""
});
setDialogOpen(true);
setFormError("");
};
const handleDialogClose = () => {
if (submitting) return;
setDialogOpen(false);
setForm(defaultForm);
setEditTarget(null);
setFormError("");
};
const handleSubmit = async () => {
if (submitting) return;
const payload = {
hostname: form.hostname.trim(),
address: form.address.trim(),
description: form.description.trim(),
operating_system: form.operating_system.trim()
};
if (!payload.hostname) {
setFormError("Hostname is required.");
return;
}
if (!payload.address) {
setFormError("Address is required.");
return;
}
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 data = await resp.json().catch(() => ({}));
if (!resp.ok) {
throw new Error(data?.error || `HTTP ${resp.status}`);
}
setDialogOpen(false);
setForm(defaultForm);
setEditTarget(null);
setFormError("");
setRows((prev) => {
const next = [...prev];
if (data?.device) {
const idx = next.findIndex((row) => row.hostname === data.device.hostname);
if (idx >= 0) next[idx] = data.device;
else next.push(data.device);
return next;
}
return prev;
});
// Ensure latest ordering by triggering refresh
loadDevices();
} catch (err) {
setFormError(String(err.message || err));
} finally {
setSubmitting(false);
}
};
const handleDelete = async () => {
if (!deleteTarget) return;
setDeleteBusy(true);
try {
const resp = await fetch(`/api/ssh_devices/${encodeURIComponent(deleteTarget.hostname)}`, {
method: "DELETE"
});
const data = await resp.json().catch(() => ({}));
if (!resp.ok) throw new Error(data?.error || `HTTP ${resp.status}`);
setRows((prev) => prev.filter((row) => row.hostname !== deleteTarget.hostname));
setDeleteTarget(null);
} catch (err) {
setError(String(err.message || err));
} finally {
setDeleteBusy(false);
}
};
return (
<Paper sx={{ m: 2, p: 0, bgcolor: "#1e1e1e" }} elevation={2}>
<Box
sx={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
p: 2,
borderBottom: "1px solid #2a2a2a"
}}
>
<Box>
<Typography variant="h6" sx={{ color: "#58a6ff", mb: 0 }}>
SSH Devices
</Typography>
<Typography variant="body2" sx={{ color: "#aaa" }}>
Manage remote endpoints reachable via SSH for playbook execution.
</Typography>
</Box>
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
<Button
size="small"
variant="outlined"
startIcon={<RefreshIcon />}
sx={{ color: "#58a6ff", borderColor: "#58a6ff" }}
onClick={loadDevices}
disabled={loading}
>
Refresh
</Button>
<Button
size="small"
variant="contained"
startIcon={<AddIcon />}
sx={{ bgcolor: "#58a6ff", color: "#0b0f19" }}
onClick={openCreate}
>
New SSH Device
</Button>
</Box>
</Box>
{error && (
<Box sx={{ px: 2, py: 1.5, color: "#ff8080" }}>
<Typography variant="body2">{error}</Typography>
</Box>
)}
{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>
</Box>
)}
<Table size="small" sx={tableStyles}>
<TableHead>
<TableRow>
<TableCell sortDirection={orderBy === "hostname" ? order : false}>
<TableSortLabel
active={orderBy === "hostname"}
direction={orderBy === "hostname" ? order : "asc"}
onClick={handleSort("hostname")}
>
Hostname
</TableSortLabel>
</TableCell>
<TableCell sortDirection={orderBy === "address" ? order : false}>
<TableSortLabel
active={orderBy === "address"}
direction={orderBy === "address" ? order : "asc"}
onClick={handleSort("address")}
>
SSH Address
</TableSortLabel>
</TableCell>
<TableCell sortDirection={orderBy === "description" ? order : false}>
<TableSortLabel
active={orderBy === "description"}
direction={orderBy === "description" ? order : "asc"}
onClick={handleSort("description")}
>
Description
</TableSortLabel>
</TableCell>
<TableCell sortDirection={orderBy === "created_at" ? order : false}>
<TableSortLabel
active={orderBy === "created_at"}
direction={orderBy === "created_at" ? order : "asc"}
onClick={handleSort("created_at")}
>
Added
</TableSortLabel>
</TableCell>
<TableCell align="right">Actions</TableCell>
</TableRow>
</TableHead>
<TableBody>
{sortedRows.map((row) => {
const createdTs = Number(row.created_at || 0) * 1000;
const createdDisplay = createdTs
? new Date(createdTs).toLocaleString()
: (row.summary?.created || "");
return (
<TableRow key={row.hostname}>
<TableCell>{row.hostname}</TableCell>
<TableCell>{row.connection_endpoint || ""}</TableCell>
<TableCell>{row.description || ""}</TableCell>
<TableCell>{createdDisplay}</TableCell>
<TableCell align="right">
<IconButton size="small" sx={{ color: "#7db7ff" }} onClick={() => openEdit(row)}>
<EditIcon fontSize="small" />
</IconButton>
<IconButton size="small" sx={{ color: "#ff8080" }} onClick={() => setDeleteTarget(row)}>
<DeleteIcon fontSize="small" />
</IconButton>
</TableCell>
</TableRow>
);
})}
{!sortedRows.length && !loading && (
<TableRow>
<TableCell colSpan={5} sx={{ textAlign: "center", color: "#888" }}>
No SSH devices have been added yet.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
<Dialog
open={dialogOpen}
onClose={handleDialogClose}
fullWidth
maxWidth="sm"
PaperProps={{ sx: { bgcolor: "#121212", color: "#fff" } }}
>
<DialogTitle>{isEdit ? "Edit SSH Device" : "New SSH Device"}</DialogTitle>
<DialogContent sx={{ display: "flex", flexDirection: "column", gap: 2, mt: 1 }}>
<TextField
label="Hostname"
value={form.hostname}
disabled={isEdit}
onChange={(e) => setForm((prev) => ({ ...prev, hostname: e.target.value }))}
fullWidth
size="small"
sx={{
"& .MuiOutlinedInput-root": {
backgroundColor: "#1f1f1f",
color: "#fff",
"& fieldset": { borderColor: "#555" },
"&:hover fieldset": { borderColor: "#888" }
},
"& .MuiInputLabel-root": { color: "#aaa" }
}}
helperText="Hostname used within Borealis (unique)."
/>
<TextField
label="SSH Address"
value={form.address}
onChange={(e) => setForm((prev) => ({ ...prev, address: e.target.value }))}
fullWidth
size="small"
sx={{
"& .MuiOutlinedInput-root": {
backgroundColor: "#1f1f1f",
color: "#fff",
"& fieldset": { borderColor: "#555" },
"&:hover fieldset": { borderColor: "#888" }
},
"& .MuiInputLabel-root": { color: "#aaa" }
}}
helperText="IP or FQDN Borealis can reach over SSH."
/>
<TextField
label="Description"
value={form.description}
onChange={(e) => setForm((prev) => ({ ...prev, description: e.target.value }))}
fullWidth
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={(e) => setForm((prev) => ({ ...prev, operating_system: e.target.value }))}
fullWidth
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={handleDialogClose} 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>
<ConfirmDeleteDialog
open={Boolean(deleteTarget)}
message={
deleteTarget
? `Remove SSH device '${deleteTarget.hostname}' from inventory?`
: ""
}
onCancel={() => setDeleteTarget(null)}
onConfirm={handleDelete}
confirmDisabled={deleteBusy}
/>
</Paper>
);
}

View File

@@ -152,9 +152,9 @@ function NavigationSidebar({ currentPage, onNavigate, isAdmin = false }) {
</Accordion>
);
})()}
{/* Devices */}
{/* Inventory */}
{(() => {
const groupActive = currentPage === "devices";
const groupActive = currentPage === "devices" || currentPage === "ssh_devices";
return (
<Accordion
expanded={expandedNav.devices}
@@ -187,11 +187,12 @@ function NavigationSidebar({ currentPage, onNavigate, isAdmin = false }) {
}}
>
<Typography sx={{ fontSize: "0.85rem", color: "#58a6ff" }}>
<b>Devices</b>
<b>Inventory</b>
</Typography>
</AccordionSummary>
<AccordionDetails sx={{ p: 0, bgcolor: "#232323" }}>
<NavItem icon={<DevicesIcon fontSize="small" />} label="Devices" pageKey="devices" />
<NavItem icon={<DevicesIcon fontSize="small" />} label="SSH Devices" pageKey="ssh_devices" indent />
</AccordionDetails>
</Accordion>
);