mirror of
https://github.com/bunny-lab-io/Borealis.git
synced 2025-10-27 01:41:58 -06:00
Additional SSH Host Implementation
This commit is contained in:
@@ -35,6 +35,7 @@ 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 SSHDevices from "./Devices/SSH_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";
|
||||||
@@ -202,6 +203,14 @@ 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":
|
||||||
|
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":
|
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" });
|
||||||
@@ -556,7 +565,7 @@ 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')) {
|
if (!isAdmin && (currentPage === 'server_info' || currentPage === 'access_credentials' || currentPage === 'access_users' || currentPage === 'ssh_devices')) {
|
||||||
setNotAuthorizedOpen(true);
|
setNotAuthorizedOpen(true);
|
||||||
setCurrentPage('devices');
|
setCurrentPage('devices');
|
||||||
}
|
}
|
||||||
@@ -584,6 +593,8 @@ const LOCAL_STORAGE_KEY = "borealis_persistent_state";
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
case "ssh_devices":
|
||||||
|
return <SSHDevices />;
|
||||||
|
|
||||||
case "device_details":
|
case "device_details":
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -48,6 +48,12 @@ export default function DeviceDetails({ device, onBack }) {
|
|||||||
const [softwareOrder, setSoftwareOrder] = useState("asc");
|
const [softwareOrder, setSoftwareOrder] = useState("asc");
|
||||||
const [softwareSearch, setSoftwareSearch] = useState("");
|
const [softwareSearch, setSoftwareSearch] = useState("");
|
||||||
const [description, setDescription] = 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 [historyRows, setHistoryRows] = useState([]);
|
||||||
const [historyOrderBy, setHistoryOrderBy] = useState("ran_at");
|
const [historyOrderBy, setHistoryOrderBy] = useState("ran_at");
|
||||||
const [historyOrder, setHistoryOrder] = useState("desc");
|
const [historyOrder, setHistoryOrder] = useState("desc");
|
||||||
@@ -70,6 +76,17 @@ export default function DeviceDetails({ device, onBack }) {
|
|||||||
return now - tsSec <= 300 ? "Online" : "Offline";
|
return now - tsSec <= 300 ? "Online" : "Offline";
|
||||||
});
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setConnectionError("");
|
||||||
|
}, [connectionDraft]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (connectionType !== "ssh") {
|
||||||
|
setConnectionMessage("");
|
||||||
|
setConnectionError("");
|
||||||
|
}
|
||||||
|
}, [connectionType]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let canceled = false;
|
let canceled = false;
|
||||||
const loadAssemblyNames = async () => {
|
const loadAssemblyNames = async () => {
|
||||||
@@ -222,6 +239,21 @@ export default function DeviceDetails({ device, onBack }) {
|
|||||||
normalizedSummary.description = detailData.description;
|
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 = {
|
const normalized = {
|
||||||
summary: normalizedSummary,
|
summary: normalizedSummary,
|
||||||
memory: Array.isArray(detailData?.memory)
|
memory: Array.isArray(detailData?.memory)
|
||||||
@@ -282,6 +314,8 @@ export default function DeviceDetails({ device, onBack }) {
|
|||||||
siteName: detailData?.site_name || "",
|
siteName: detailData?.site_name || "",
|
||||||
siteDescription: detailData?.site_description || "",
|
siteDescription: detailData?.site_description || "",
|
||||||
status: detailData?.status || "",
|
status: detailData?.status || "",
|
||||||
|
connectionType: connectionTypeValue,
|
||||||
|
connectionEndpoint: connectionEndpointValue,
|
||||||
};
|
};
|
||||||
setMeta(metaPayload);
|
setMeta(metaPayload);
|
||||||
setDescription(normalizedSummary.description || detailData?.description || "");
|
setDescription(normalizedSummary.description || detailData?.description || "");
|
||||||
@@ -313,6 +347,43 @@ export default function DeviceDetails({ device, onBack }) {
|
|||||||
return (meta?.hostname || agent?.hostname || device?.hostname || "").trim();
|
return (meta?.hostname || agent?.hostname || device?.hostname || "").trim();
|
||||||
}, [meta?.hostname, agent?.hostname, device?.hostname]);
|
}, [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 () => {
|
const loadHistory = useCallback(async () => {
|
||||||
if (!activityHostname) return;
|
if (!activityHostname) return;
|
||||||
try {
|
try {
|
||||||
@@ -689,6 +760,44 @@ export default function DeviceDetails({ device, onBack }) {
|
|||||||
/>
|
/>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</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) => (
|
{summaryItems.map((item) => (
|
||||||
<TableRow key={item.label}>
|
<TableRow key={item.label}>
|
||||||
<TableCell sx={{ fontWeight: 500 }}>{item.label}</TableCell>
|
<TableCell sx={{ fontWeight: 500 }}>{item.label}</TableCell>
|
||||||
|
|||||||
@@ -304,6 +304,8 @@ export default function DeviceList({ onSelectDevice }) {
|
|||||||
summary.uptime ||
|
summary.uptime ||
|
||||||
0
|
0
|
||||||
) || 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 memoryList = Array.isArray(device.memory) ? device.memory : [];
|
||||||
const networkList = Array.isArray(device.network) ? device.network : [];
|
const networkList = Array.isArray(device.network) ? device.network : [];
|
||||||
@@ -357,6 +359,9 @@ export default function DeviceList({ onSelectDevice }) {
|
|||||||
cpuRaw: normalizeJson(cpuObj),
|
cpuRaw: normalizeJson(cpuObj),
|
||||||
summary,
|
summary,
|
||||||
details: device.details || {},
|
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={{ 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 }}>
|
||||||
Devices
|
Device Inventory
|
||||||
</Typography>
|
</Typography>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center' }}>
|
<Box sx={{ display: 'flex', alignItems: 'center' }}>
|
||||||
{/* Views dropdown + add button */}
|
{/* 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>
|
</TableCell>
|
||||||
);
|
);
|
||||||
case "description":
|
case "description":
|
||||||
|
|||||||
461
Data/Server/WebUI/src/Devices/SSH_Devices.jsx
Normal file
461
Data/Server/WebUI/src/Devices/SSH_Devices.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -152,9 +152,9 @@ function NavigationSidebar({ currentPage, onNavigate, isAdmin = false }) {
|
|||||||
</Accordion>
|
</Accordion>
|
||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
{/* Devices */}
|
{/* Inventory */}
|
||||||
{(() => {
|
{(() => {
|
||||||
const groupActive = currentPage === "devices";
|
const groupActive = currentPage === "devices" || currentPage === "ssh_devices";
|
||||||
return (
|
return (
|
||||||
<Accordion
|
<Accordion
|
||||||
expanded={expandedNav.devices}
|
expanded={expandedNav.devices}
|
||||||
@@ -187,11 +187,12 @@ function NavigationSidebar({ currentPage, onNavigate, isAdmin = false }) {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Typography sx={{ fontSize: "0.85rem", color: "#58a6ff" }}>
|
<Typography sx={{ fontSize: "0.85rem", color: "#58a6ff" }}>
|
||||||
<b>Devices</b>
|
<b>Inventory</b>
|
||||||
</Typography>
|
</Typography>
|
||||||
</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="SSH Devices" pageKey="ssh_devices" indent />
|
||||||
</AccordionDetails>
|
</AccordionDetails>
|
||||||
</Accordion>
|
</Accordion>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3510,6 +3510,8 @@ _DEVICE_TABLE_COLUMNS = [
|
|||||||
"operating_system",
|
"operating_system",
|
||||||
"uptime",
|
"uptime",
|
||||||
"agent_id",
|
"agent_id",
|
||||||
|
"connection_type",
|
||||||
|
"connection_endpoint",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -3595,6 +3597,8 @@ def _assemble_device_snapshot(record: Dict[str, Any]) -> Dict[str, Any]:
|
|||||||
"uptime_sec": uptime_val,
|
"uptime_sec": uptime_val,
|
||||||
"created_at": created_ts,
|
"created_at": created_ts,
|
||||||
"created": _ts_to_human(created_ts),
|
"created": _ts_to_human(created_ts),
|
||||||
|
"connection_type": _clean_device_str(record.get("connection_type")) or "",
|
||||||
|
"connection_endpoint": _clean_device_str(record.get("connection_endpoint")) or "",
|
||||||
}
|
}
|
||||||
|
|
||||||
details = {
|
details = {
|
||||||
@@ -3630,6 +3634,8 @@ def _assemble_device_snapshot(record: Dict[str, Any]) -> Dict[str, Any]:
|
|||||||
"operating_system": summary.get("operating_system", ""),
|
"operating_system": summary.get("operating_system", ""),
|
||||||
"uptime": uptime_val,
|
"uptime": uptime_val,
|
||||||
"agent_id": summary.get("agent_id", ""),
|
"agent_id": summary.get("agent_id", ""),
|
||||||
|
"connection_type": summary.get("connection_type", ""),
|
||||||
|
"connection_endpoint": summary.get("connection_endpoint", ""),
|
||||||
"details": details,
|
"details": details,
|
||||||
"summary": summary,
|
"summary": summary,
|
||||||
}
|
}
|
||||||
@@ -3737,6 +3743,17 @@ def _extract_device_columns(details: Dict[str, Any]) -> Dict[str, Any]:
|
|||||||
)
|
)
|
||||||
payload["uptime"] = _coerce_int(uptime_value)
|
payload["uptime"] = _coerce_int(uptime_value)
|
||||||
payload["agent_id"] = _clean_device_str(summary.get("agent_id"))
|
payload["agent_id"] = _clean_device_str(summary.get("agent_id"))
|
||||||
|
payload["connection_type"] = _clean_device_str(
|
||||||
|
summary.get("connection_type")
|
||||||
|
or summary.get("remote_type")
|
||||||
|
)
|
||||||
|
payload["connection_endpoint"] = _clean_device_str(
|
||||||
|
summary.get("connection_endpoint")
|
||||||
|
or summary.get("connection_address")
|
||||||
|
or summary.get("address")
|
||||||
|
or summary.get("external_ip")
|
||||||
|
or summary.get("internal_ip")
|
||||||
|
)
|
||||||
return payload
|
return payload
|
||||||
|
|
||||||
|
|
||||||
@@ -3793,8 +3810,10 @@ def _device_upsert(
|
|||||||
last_user,
|
last_user,
|
||||||
operating_system,
|
operating_system,
|
||||||
uptime,
|
uptime,
|
||||||
agent_id
|
agent_id,
|
||||||
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
connection_type,
|
||||||
|
connection_endpoint
|
||||||
|
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
||||||
ON CONFLICT(hostname) DO UPDATE SET
|
ON CONFLICT(hostname) DO UPDATE SET
|
||||||
description=excluded.description,
|
description=excluded.description,
|
||||||
created_at=COALESCE({DEVICE_TABLE}.created_at, excluded.created_at),
|
created_at=COALESCE({DEVICE_TABLE}.created_at, excluded.created_at),
|
||||||
@@ -3814,7 +3833,9 @@ def _device_upsert(
|
|||||||
last_user=COALESCE(NULLIF(excluded.last_user, ''), {DEVICE_TABLE}.last_user),
|
last_user=COALESCE(NULLIF(excluded.last_user, ''), {DEVICE_TABLE}.last_user),
|
||||||
operating_system=COALESCE(NULLIF(excluded.operating_system, ''), {DEVICE_TABLE}.operating_system),
|
operating_system=COALESCE(NULLIF(excluded.operating_system, ''), {DEVICE_TABLE}.operating_system),
|
||||||
uptime=COALESCE(NULLIF(excluded.uptime, 0), {DEVICE_TABLE}.uptime),
|
uptime=COALESCE(NULLIF(excluded.uptime, 0), {DEVICE_TABLE}.uptime),
|
||||||
agent_id=COALESCE(NULLIF(excluded.agent_id, ''), {DEVICE_TABLE}.agent_id)
|
agent_id=COALESCE(NULLIF(excluded.agent_id, ''), {DEVICE_TABLE}.agent_id),
|
||||||
|
connection_type=COALESCE(NULLIF(excluded.connection_type, ''), {DEVICE_TABLE}.connection_type),
|
||||||
|
connection_endpoint=COALESCE(NULLIF(excluded.connection_endpoint, ''), {DEVICE_TABLE}.connection_endpoint)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
params: List[Any] = [
|
params: List[Any] = [
|
||||||
@@ -3838,6 +3859,8 @@ def _device_upsert(
|
|||||||
column_values.get("operating_system"),
|
column_values.get("operating_system"),
|
||||||
column_values.get("uptime"),
|
column_values.get("uptime"),
|
||||||
column_values.get("agent_id"),
|
column_values.get("agent_id"),
|
||||||
|
column_values.get("connection_type"),
|
||||||
|
column_values.get("connection_endpoint"),
|
||||||
]
|
]
|
||||||
cur.execute(sql, params)
|
cur.execute(sql, params)
|
||||||
|
|
||||||
@@ -4166,6 +4189,8 @@ def init_db():
|
|||||||
_ensure_column("operating_system", "TEXT")
|
_ensure_column("operating_system", "TEXT")
|
||||||
_ensure_column("uptime", "INTEGER")
|
_ensure_column("uptime", "INTEGER")
|
||||||
_ensure_column("agent_id", "TEXT")
|
_ensure_column("agent_id", "TEXT")
|
||||||
|
_ensure_column("connection_type", "TEXT")
|
||||||
|
_ensure_column("connection_endpoint", "TEXT")
|
||||||
|
|
||||||
details_rows: List[Tuple[Any, ...]] = []
|
details_rows: List[Tuple[Any, ...]] = []
|
||||||
if "details" in existing_cols:
|
if "details" in existing_cols:
|
||||||
@@ -4196,6 +4221,8 @@ def init_db():
|
|||||||
"operating_system",
|
"operating_system",
|
||||||
"uptime",
|
"uptime",
|
||||||
"agent_id",
|
"agent_id",
|
||||||
|
"connection_type",
|
||||||
|
"connection_endpoint",
|
||||||
)]
|
)]
|
||||||
cur.execute(
|
cur.execute(
|
||||||
f"""
|
f"""
|
||||||
@@ -4203,7 +4230,7 @@ def init_db():
|
|||||||
SET memory=?, network=?, software=?, storage=?, cpu=?,
|
SET memory=?, network=?, software=?, storage=?, cpu=?,
|
||||||
device_type=?, domain=?, external_ip=?, internal_ip=?,
|
device_type=?, domain=?, external_ip=?, internal_ip=?,
|
||||||
last_reboot=?, last_seen=?, last_user=?, operating_system=?,
|
last_reboot=?, last_seen=?, last_user=?, operating_system=?,
|
||||||
uptime=?, agent_id=?
|
uptime=?, agent_id=?, connection_type=?, connection_endpoint=?
|
||||||
WHERE hostname=?
|
WHERE hostname=?
|
||||||
""",
|
""",
|
||||||
params + [hostname],
|
params + [hostname],
|
||||||
@@ -4242,7 +4269,9 @@ def init_db():
|
|||||||
last_user TEXT,
|
last_user TEXT,
|
||||||
operating_system TEXT,
|
operating_system TEXT,
|
||||||
uptime INTEGER,
|
uptime INTEGER,
|
||||||
agent_id TEXT
|
agent_id TEXT,
|
||||||
|
connection_type TEXT,
|
||||||
|
connection_endpoint TEXT
|
||||||
)
|
)
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
@@ -4934,28 +4963,39 @@ def search_suggest():
|
|||||||
|
|
||||||
# Endpoint: /api/devices — methods GET.
|
# Endpoint: /api/devices — methods GET.
|
||||||
|
|
||||||
@app.route("/api/devices", methods=["GET"])
|
def _fetch_devices(
|
||||||
def list_devices():
|
*,
|
||||||
"""Return all devices with expanded columns for the WebUI."""
|
connection_type: Optional[str] = None,
|
||||||
|
hostname: Optional[str] = None,
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
try:
|
try:
|
||||||
conn = _db_conn()
|
conn = _db_conn()
|
||||||
cur = conn.cursor()
|
cur = conn.cursor()
|
||||||
cur.execute(
|
sql = f"""
|
||||||
f"""
|
|
||||||
SELECT {_device_column_sql('d')},
|
SELECT {_device_column_sql('d')},
|
||||||
s.id, s.name, s.description
|
s.id, s.name, s.description
|
||||||
FROM {DEVICE_TABLE} d
|
FROM {DEVICE_TABLE} d
|
||||||
LEFT JOIN device_sites ds ON ds.device_hostname = d.hostname
|
LEFT JOIN device_sites ds ON ds.device_hostname = d.hostname
|
||||||
LEFT JOIN sites s ON s.id = ds.site_id
|
LEFT JOIN sites s ON s.id = ds.site_id
|
||||||
"""
|
"""
|
||||||
)
|
clauses: List[str] = []
|
||||||
|
params: List[Any] = []
|
||||||
|
if connection_type:
|
||||||
|
clauses.append("LOWER(d.connection_type) = LOWER(?)")
|
||||||
|
params.append(connection_type)
|
||||||
|
if hostname:
|
||||||
|
clauses.append("LOWER(d.hostname) = LOWER(?)")
|
||||||
|
params.append(hostname.lower())
|
||||||
|
if clauses:
|
||||||
|
sql += " WHERE " + " AND ".join(clauses)
|
||||||
|
cur.execute(sql, params)
|
||||||
rows = cur.fetchall()
|
rows = cur.fetchall()
|
||||||
conn.close()
|
conn.close()
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
return jsonify({"error": str(exc)}), 500
|
raise RuntimeError(str(exc)) from exc
|
||||||
|
|
||||||
devices = []
|
|
||||||
now = time.time()
|
now = time.time()
|
||||||
|
devices: List[Dict[str, Any]] = []
|
||||||
for row in rows:
|
for row in rows:
|
||||||
core = row[: len(_DEVICE_TABLE_COLUMNS)]
|
core = row[: len(_DEVICE_TABLE_COLUMNS)]
|
||||||
site_id, site_name, site_description = row[len(_DEVICE_TABLE_COLUMNS) :]
|
site_id, site_name, site_description = row[len(_DEVICE_TABLE_COLUMNS) :]
|
||||||
@@ -4989,6 +5029,8 @@ def list_devices():
|
|||||||
"domain": snapshot.get("domain") or "",
|
"domain": snapshot.get("domain") or "",
|
||||||
"external_ip": snapshot.get("external_ip") or summary.get("external_ip") or "",
|
"external_ip": snapshot.get("external_ip") or summary.get("external_ip") or "",
|
||||||
"internal_ip": snapshot.get("internal_ip") or summary.get("internal_ip") or "",
|
"internal_ip": snapshot.get("internal_ip") or summary.get("internal_ip") or "",
|
||||||
|
"connection_type": snapshot.get("connection_type") or summary.get("connection_type") or "",
|
||||||
|
"connection_endpoint": snapshot.get("connection_endpoint") or summary.get("connection_endpoint") or "",
|
||||||
"last_reboot": snapshot.get("last_reboot") or summary.get("last_reboot") or "",
|
"last_reboot": snapshot.get("last_reboot") or summary.get("last_reboot") or "",
|
||||||
"last_seen": last_seen,
|
"last_seen": last_seen,
|
||||||
"last_seen_iso": snapshot.get("last_seen_iso") or _ts_to_iso(last_seen),
|
"last_seen_iso": snapshot.get("last_seen_iso") or _ts_to_iso(last_seen),
|
||||||
@@ -5005,10 +5047,159 @@ def list_devices():
|
|||||||
"status": status,
|
"status": status,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
return devices
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/devices", methods=["GET"])
|
||||||
|
def list_devices():
|
||||||
|
"""Return all devices with expanded columns for the WebUI."""
|
||||||
|
try:
|
||||||
|
devices = _fetch_devices()
|
||||||
|
except RuntimeError as exc:
|
||||||
|
return jsonify({"error": str(exc)}), 500
|
||||||
return jsonify({"devices": devices})
|
return jsonify({"devices": devices})
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/ssh_devices", methods=["GET", "POST"])
|
||||||
|
def api_ssh_devices():
|
||||||
|
chk = _require_admin()
|
||||||
|
if chk:
|
||||||
|
return chk
|
||||||
|
if request.method == "GET":
|
||||||
|
try:
|
||||||
|
devices = _fetch_devices(connection_type="ssh")
|
||||||
|
return jsonify({"devices": devices})
|
||||||
|
except RuntimeError as exc:
|
||||||
|
return jsonify({"error": str(exc)}), 500
|
||||||
|
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
hostname = _clean_device_str(data.get("hostname")) or ""
|
||||||
|
address = _clean_device_str(data.get("address") or data.get("connection_endpoint") or data.get("endpoint")) or ""
|
||||||
|
description = _clean_device_str(data.get("description")) or ""
|
||||||
|
os_hint = _clean_device_str(data.get("operating_system") or data.get("os")) or ""
|
||||||
|
if not hostname:
|
||||||
|
return jsonify({"error": "hostname is required"}), 400
|
||||||
|
if not address:
|
||||||
|
return jsonify({"error": "address is required"}), 400
|
||||||
|
|
||||||
|
now_ts = _now_ts()
|
||||||
|
conn = None
|
||||||
|
try:
|
||||||
|
conn = _db_conn()
|
||||||
|
cur = conn.cursor()
|
||||||
|
existing = _load_device_snapshot(cur, hostname=hostname)
|
||||||
|
if existing and (existing.get("summary", {}).get("connection_type") or "").lower() not in ("", "ssh"):
|
||||||
|
conn.close()
|
||||||
|
return jsonify({"error": "Device already exists and is managed by an agent"}), 409
|
||||||
|
|
||||||
|
summary_payload = {
|
||||||
|
"connection_type": "ssh",
|
||||||
|
"connection_endpoint": address,
|
||||||
|
"internal_ip": address,
|
||||||
|
"external_ip": address,
|
||||||
|
"device_type": "SSH Remote",
|
||||||
|
"operating_system": os_hint or (existing.get("summary", {}).get("operating_system") if existing else ""),
|
||||||
|
"last_seen": 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
_device_upsert(
|
||||||
|
cur,
|
||||||
|
hostname,
|
||||||
|
description,
|
||||||
|
{"summary": summary_payload},
|
||||||
|
now_ts,
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
except Exception as exc:
|
||||||
|
if conn:
|
||||||
|
conn.close()
|
||||||
|
return jsonify({"error": str(exc)}), 500
|
||||||
|
|
||||||
|
try:
|
||||||
|
devices = _fetch_devices(hostname=hostname)
|
||||||
|
except RuntimeError as exc:
|
||||||
|
return jsonify({"error": str(exc)}), 500
|
||||||
|
device = devices[0] if devices else None
|
||||||
|
return jsonify({"device": device}), 201
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/ssh_devices/<hostname>", methods=["PUT", "DELETE"])
|
||||||
|
def api_ssh_device_detail(hostname: str):
|
||||||
|
chk = _require_admin()
|
||||||
|
if chk:
|
||||||
|
return chk
|
||||||
|
normalized_host = _clean_device_str(hostname) or ""
|
||||||
|
if not normalized_host:
|
||||||
|
return jsonify({"error": "invalid hostname"}), 400
|
||||||
|
|
||||||
|
conn = None
|
||||||
|
try:
|
||||||
|
conn = _db_conn()
|
||||||
|
cur = conn.cursor()
|
||||||
|
existing = _load_device_snapshot(cur, hostname=normalized_host)
|
||||||
|
if not existing:
|
||||||
|
conn.close()
|
||||||
|
return jsonify({"error": "device not found"}), 404
|
||||||
|
existing_type = (existing.get("summary", {}).get("connection_type") or "").lower()
|
||||||
|
if existing_type != "ssh":
|
||||||
|
conn.close()
|
||||||
|
return jsonify({"error": "device is not managed as SSH"}), 400
|
||||||
|
|
||||||
|
if request.method == "DELETE":
|
||||||
|
cur.execute("DELETE FROM device_sites WHERE device_hostname = ?", (normalized_host,))
|
||||||
|
cur.execute(f"DELETE FROM {DEVICE_TABLE} WHERE hostname = ?", (normalized_host,))
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
return jsonify({"status": "ok"})
|
||||||
|
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
new_address = _clean_device_str(data.get("address") or data.get("connection_endpoint") or data.get("endpoint"))
|
||||||
|
new_description = data.get("description")
|
||||||
|
new_os = _clean_device_str(data.get("operating_system") or data.get("os"))
|
||||||
|
|
||||||
|
summary = existing.get("summary", {})
|
||||||
|
description_value = summary.get("description") or existing.get("description") or ""
|
||||||
|
if new_description is not None:
|
||||||
|
try:
|
||||||
|
description_value = str(new_description).strip()
|
||||||
|
except Exception:
|
||||||
|
description_value = summary.get("description") or ""
|
||||||
|
|
||||||
|
endpoint_value = new_address or summary.get("connection_endpoint") or ""
|
||||||
|
os_value = new_os or summary.get("operating_system") or ""
|
||||||
|
summary_payload = {
|
||||||
|
"connection_type": "ssh",
|
||||||
|
"connection_endpoint": endpoint_value,
|
||||||
|
"internal_ip": endpoint_value or summary.get("internal_ip") or "",
|
||||||
|
"external_ip": endpoint_value or summary.get("external_ip") or "",
|
||||||
|
"device_type": summary.get("device_type") or "SSH Remote",
|
||||||
|
"operating_system": os_value,
|
||||||
|
"last_seen": 0,
|
||||||
|
}
|
||||||
|
created_ts = summary.get("created_at") or existing.get("created_at") or _now_ts()
|
||||||
|
_device_upsert(
|
||||||
|
cur,
|
||||||
|
normalized_host,
|
||||||
|
description_value,
|
||||||
|
{"summary": summary_payload},
|
||||||
|
created_ts,
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
except Exception as exc:
|
||||||
|
if conn:
|
||||||
|
conn.close()
|
||||||
|
return jsonify({"error": str(exc)}), 500
|
||||||
|
|
||||||
|
try:
|
||||||
|
devices = _fetch_devices(hostname=normalized_host)
|
||||||
|
except RuntimeError as exc:
|
||||||
|
return jsonify({"error": str(exc)}), 500
|
||||||
|
device = devices[0] if devices else None
|
||||||
|
return jsonify({"device": device})
|
||||||
|
|
||||||
|
|
||||||
# Endpoint: /api/devices/<guid> — methods GET.
|
# Endpoint: /api/devices/<guid> — methods GET.
|
||||||
|
|
||||||
@app.route("/api/devices/<guid>", methods=["GET"])
|
@app.route("/api/devices/<guid>", methods=["GET"])
|
||||||
|
|||||||
Reference in New Issue
Block a user