From c0f47075d6aa0d8f715f5eab46c05254df27707a Mon Sep 17 00:00:00 2001 From: Nicole Rappe Date: Sat, 11 Oct 2025 04:05:22 -0600 Subject: [PATCH] Additional SSH Host Implementation --- Data/Server/WebUI/src/App.jsx | 13 +- .../WebUI/src/Devices/Device_Details.jsx | 109 +++++ Data/Server/WebUI/src/Devices/Device_List.jsx | 29 +- Data/Server/WebUI/src/Devices/SSH_Devices.jsx | 461 ++++++++++++++++++ Data/Server/WebUI/src/Navigation_Sidebar.jsx | 7 +- Data/Server/server.py | 219 ++++++++- 6 files changed, 818 insertions(+), 20 deletions(-) create mode 100644 Data/Server/WebUI/src/Devices/SSH_Devices.jsx diff --git a/Data/Server/WebUI/src/App.jsx b/Data/Server/WebUI/src/App.jsx index 7af4573..acac841 100644 --- a/Data/Server/WebUI/src/App.jsx +++ b/Data/Server/WebUI/src/App.jsx @@ -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 ; case "device_details": return ( diff --git a/Data/Server/WebUI/src/Devices/Device_Details.jsx b/Data/Server/WebUI/src/Devices/Device_Details.jsx index a85b22f..1190b42 100644 --- a/Data/Server/WebUI/src/Devices/Device_Details.jsx +++ b/Data/Server/WebUI/src/Devices/Device_Details.jsx @@ -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 }) { /> + {connectionType === "ssh" && ( + + SSH Endpoint + + + setConnectionDraft(e.target.value)} + placeholder="user@host or host" + sx={{ + maxWidth: 300, + input: { color: '#fff' }, + '& .MuiOutlinedInput-root': { + '& fieldset': { borderColor: '#555' }, + '&:hover fieldset': { borderColor: '#888' } + } + }} + /> + + + {connectionMessage && ( + {connectionMessage} + )} + {connectionError && ( + {connectionError} + )} + + + )} {summaryItems.map((item) => ( {item.label} diff --git a/Data/Server/WebUI/src/Devices/Device_List.jsx b/Data/Server/WebUI/src/Devices/Device_List.jsx index a7ad04f..1d0efb3 100644 --- a/Data/Server/WebUI/src/Devices/Device_List.jsx +++ b/Data/Server/WebUI/src/Devices/Device_List.jsx @@ -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 }) { - Devices + Device Inventory {/* Views dropdown + add button */} @@ -856,7 +861,27 @@ export default function DeviceList({ onSelectDevice }) { }, }} > - {r.hostname} + + {r.isRemote && ( + + SSH + + )} + {r.hostname} + ); case "description": diff --git a/Data/Server/WebUI/src/Devices/SSH_Devices.jsx b/Data/Server/WebUI/src/Devices/SSH_Devices.jsx new file mode 100644 index 0000000..2d61743 --- /dev/null +++ b/Data/Server/WebUI/src/Devices/SSH_Devices.jsx @@ -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 ( + + + + + SSH Devices + + + Manage remote endpoints reachable via SSH for playbook execution. + + + + + + + + + {error && ( + + {error} + + )} + {loading && ( + + + Loading SSH devices… + + )} + + + + + + + Hostname + + + + + SSH Address + + + + + Description + + + + + Added + + + Actions + + + + {sortedRows.map((row) => { + const createdTs = Number(row.created_at || 0) * 1000; + const createdDisplay = createdTs + ? new Date(createdTs).toLocaleString() + : (row.summary?.created || ""); + return ( + + {row.hostname} + {row.connection_endpoint || ""} + {row.description || ""} + {createdDisplay} + + openEdit(row)}> + + + setDeleteTarget(row)}> + + + + + ); + })} + {!sortedRows.length && !loading && ( + + + No SSH devices have been added yet. + + + )} + +
+ + + {isEdit ? "Edit SSH Device" : "New SSH Device"} + + 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)." + /> + 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." + /> + 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" } + }} + /> + 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 && ( + + {error} + + )} + + + + + + + + setDeleteTarget(null)} + onConfirm={handleDelete} + confirmDisabled={deleteBusy} + /> +
+ ); +} diff --git a/Data/Server/WebUI/src/Navigation_Sidebar.jsx b/Data/Server/WebUI/src/Navigation_Sidebar.jsx index 266ed69..eda7574 100644 --- a/Data/Server/WebUI/src/Navigation_Sidebar.jsx +++ b/Data/Server/WebUI/src/Navigation_Sidebar.jsx @@ -152,9 +152,9 @@ function NavigationSidebar({ currentPage, onNavigate, isAdmin = false }) { ); })()} - {/* Devices */} + {/* Inventory */} {(() => { - const groupActive = currentPage === "devices"; + const groupActive = currentPage === "devices" || currentPage === "ssh_devices"; return ( - Devices + Inventory } label="Devices" pageKey="devices" /> + } label="SSH Devices" pageKey="ssh_devices" indent /> ); diff --git a/Data/Server/server.py b/Data/Server/server.py index 2c62e80..8c5a1ab 100644 --- a/Data/Server/server.py +++ b/Data/Server/server.py @@ -3510,6 +3510,8 @@ _DEVICE_TABLE_COLUMNS = [ "operating_system", "uptime", "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, "created_at": 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 = { @@ -3630,6 +3634,8 @@ def _assemble_device_snapshot(record: Dict[str, Any]) -> Dict[str, Any]: "operating_system": summary.get("operating_system", ""), "uptime": uptime_val, "agent_id": summary.get("agent_id", ""), + "connection_type": summary.get("connection_type", ""), + "connection_endpoint": summary.get("connection_endpoint", ""), "details": details, "summary": summary, } @@ -3737,6 +3743,17 @@ def _extract_device_columns(details: Dict[str, Any]) -> Dict[str, Any]: ) payload["uptime"] = _coerce_int(uptime_value) 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 @@ -3793,8 +3810,10 @@ def _device_upsert( last_user, operating_system, uptime, - agent_id - ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + agent_id, + connection_type, + connection_endpoint + ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) ON CONFLICT(hostname) DO UPDATE SET description=excluded.description, 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), operating_system=COALESCE(NULLIF(excluded.operating_system, ''), {DEVICE_TABLE}.operating_system), 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] = [ @@ -3838,6 +3859,8 @@ def _device_upsert( column_values.get("operating_system"), column_values.get("uptime"), column_values.get("agent_id"), + column_values.get("connection_type"), + column_values.get("connection_endpoint"), ] cur.execute(sql, params) @@ -4166,6 +4189,8 @@ def init_db(): _ensure_column("operating_system", "TEXT") _ensure_column("uptime", "INTEGER") _ensure_column("agent_id", "TEXT") + _ensure_column("connection_type", "TEXT") + _ensure_column("connection_endpoint", "TEXT") details_rows: List[Tuple[Any, ...]] = [] if "details" in existing_cols: @@ -4196,6 +4221,8 @@ def init_db(): "operating_system", "uptime", "agent_id", + "connection_type", + "connection_endpoint", )] cur.execute( f""" @@ -4203,7 +4230,7 @@ def init_db(): SET memory=?, network=?, software=?, storage=?, cpu=?, device_type=?, domain=?, external_ip=?, internal_ip=?, last_reboot=?, last_seen=?, last_user=?, operating_system=?, - uptime=?, agent_id=? + uptime=?, agent_id=?, connection_type=?, connection_endpoint=? WHERE hostname=? """, params + [hostname], @@ -4242,7 +4269,9 @@ def init_db(): last_user TEXT, operating_system TEXT, 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. -@app.route("/api/devices", methods=["GET"]) -def list_devices(): - """Return all devices with expanded columns for the WebUI.""" +def _fetch_devices( + *, + connection_type: Optional[str] = None, + hostname: Optional[str] = None, +) -> List[Dict[str, Any]]: try: conn = _db_conn() cur = conn.cursor() - cur.execute( - f""" + sql = f""" SELECT {_device_column_sql('d')}, s.id, s.name, s.description FROM {DEVICE_TABLE} d LEFT JOIN device_sites ds ON ds.device_hostname = d.hostname 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() conn.close() except Exception as exc: - return jsonify({"error": str(exc)}), 500 + raise RuntimeError(str(exc)) from exc - devices = [] now = time.time() + devices: List[Dict[str, Any]] = [] for row in rows: core = 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 "", "external_ip": snapshot.get("external_ip") or summary.get("external_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_seen": 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, } ) + 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}) +@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/", 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/ — methods GET. @app.route("/api/devices/", methods=["GET"])