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.
+
+
+
+ }
+ sx={{ color: "#58a6ff", borderColor: "#58a6ff" }}
+ onClick={loadDevices}
+ disabled={loading}
+ >
+ Refresh
+
+ }
+ sx={{ bgcolor: "#58a6ff", color: "#0b0f19" }}
+ onClick={openCreate}
+ >
+ New SSH Device
+
+
+
+
+ {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.
+
+
+ )}
+
+
+
+
+
+ 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"])