diff --git a/.gitignore b/.gitignore index 037425d..9e475ed 100644 --- a/.gitignore +++ b/.gitignore @@ -25,4 +25,6 @@ Borealis-Server.exe /Dependencies/NodeJS/ /Dependencies/Python/ /Dependencies/AutoHotKey/ -/Data/Server/Python_API_Endpoints/Tesseract-OCR/ \ No newline at end of file +/Data/Server/Python_API_Endpoints/Tesseract-OCR/ +# Device database +/Databases/ diff --git a/Data/Agent/borealis-agent.py b/Data/Agent/borealis-agent.py index 5939332..00df6b1 100644 --- a/Data/Agent/borealis-agent.py +++ b/Data/Agent/borealis-agent.py @@ -14,6 +14,16 @@ import random # Macro Randomization import platform # OS Detection import importlib.util import time # Heartbeat timestamps +import subprocess +import getpass +import datetime + +import requests +try: + import psutil +except Exception: + psutil = None +import aiohttp import socketio from qasync import QEventLoop @@ -205,6 +215,407 @@ async def send_heartbeat(): print(f"[WARN] heartbeat emit failed: {e}") await asyncio.sleep(5) +# ---------------- Detailed Agent Data ---------------- + +def _get_internal_ip(): + try: + s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + s.connect(("8.8.8.8", 80)) + ip = s.getsockname()[0] + s.close() + return ip + except Exception: + return "unknown" + +def collect_summary(): + try: + username = getpass.getuser() + domain = os.environ.get("USERDOMAIN") or socket.gethostname() + last_user = f"{domain}\\{username}" if username else "unknown" + except Exception: + last_user = "unknown" + try: + last_reboot = "unknown" + if psutil: + try: + last_reboot = time.strftime( + "%Y-%m-%d %H:%M:%S", + time.localtime(psutil.boot_time()), + ) + except Exception: + last_reboot = "unknown" + if last_reboot == "unknown": + plat = platform.system().lower() + if plat == "windows": + try: + out = subprocess.run( + ["wmic", "os", "get", "lastbootuptime"], + capture_output=True, + text=True, + timeout=60, + ) + raw = "".join(out.stdout.splitlines()[1:]).strip() + if raw: + boot = datetime.datetime.strptime(raw.split(".")[0], "%Y%m%d%H%M%S") + last_reboot = boot.strftime("%Y-%m-%d %H:%M:%S") + except FileNotFoundError: + ps_cmd = "(Get-CimInstance Win32_OperatingSystem).LastBootUpTime" + out = subprocess.run( + ["powershell", "-NoProfile", "-Command", ps_cmd], + capture_output=True, + text=True, + timeout=60, + ) + raw = out.stdout.strip() + if raw: + try: + boot = datetime.datetime.strptime(raw.split(".")[0], "%Y%m%d%H%M%S") + last_reboot = boot.strftime("%Y-%m-%d %H:%M:%S") + except Exception: + pass + else: + try: + out = subprocess.run( + ["uptime", "-s"], capture_output=True, text=True, timeout=30 + ) + val = out.stdout.strip() + if val: + last_reboot = val + except Exception: + pass + except Exception: + last_reboot = "unknown" + + created = CONFIG.data.get("created") + if not created: + created = time.strftime("%Y-%m-%d %H:%M:%S") + CONFIG.data["created"] = created + CONFIG._write() + + try: + external_ip = requests.get("https://api.ipify.org", timeout=5).text.strip() + except Exception: + external_ip = "unknown" + + return { + "hostname": socket.gethostname(), + "operating_system": CONFIG.data.get("agent_operating_system", detect_agent_os()), + "last_user": last_user, + "internal_ip": _get_internal_ip(), + "external_ip": external_ip, + "last_reboot": last_reboot, + "created": created, + } + +def collect_software(): + items = [] + plat = platform.system().lower() + try: + if plat == "windows": + try: + out = subprocess.run(["wmic", "product", "get", "name,version"], + capture_output=True, text=True, timeout=60) + for line in out.stdout.splitlines(): + if line.strip() and not line.lower().startswith("name"): + parts = line.strip().split(" ") + name = parts[0].strip() + version = parts[-1].strip() if len(parts) > 1 else "" + if name: + items.append({"name": name, "version": version}) + except FileNotFoundError: + ps_cmd = ( + "Get-ItemProperty " + "'HKLM:\\Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\*'," + "'HKLM:\\Software\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\*' " + "| Where-Object { $_.DisplayName } " + "| Select-Object DisplayName,DisplayVersion " + "| ConvertTo-Json" + ) + out = subprocess.run( + ["powershell", "-NoProfile", "-Command", ps_cmd], + capture_output=True, + text=True, + timeout=60, + ) + data = json.loads(out.stdout or "[]") + if isinstance(data, dict): + data = [data] + for pkg in data: + name = pkg.get("DisplayName") + if name: + items.append({ + "name": name, + "version": pkg.get("DisplayVersion", "") + }) + elif plat == "linux": + out = subprocess.run(["dpkg-query", "-W", "-f=${Package}\t${Version}\n"], capture_output=True, text=True) + for line in out.stdout.splitlines(): + if "\t" in line: + name, version = line.split("\t", 1) + items.append({"name": name, "version": version}) + else: + out = subprocess.run([sys.executable, "-m", "pip", "list", "--format", "json"], capture_output=True, text=True) + data = json.loads(out.stdout or "[]") + for pkg in data: + items.append({"name": pkg.get("name"), "version": pkg.get("version")}) + except Exception as e: + print(f"[WARN] collect_software failed: {e}") + return items[:100] + +def collect_memory(): + entries = [] + plat = platform.system().lower() + try: + if plat == "windows": + try: + out = subprocess.run( + ["wmic", "memorychip", "get", "BankLabel,Speed,SerialNumber,Capacity"], + capture_output=True, + text=True, + timeout=60, + ) + lines = [l for l in out.stdout.splitlines() if l.strip() and "BankLabel" not in l] + for line in lines: + parts = [p for p in line.split() if p] + if len(parts) >= 4: + entries.append({ + "slot": parts[0], + "speed": parts[1], + "serial": parts[2], + "capacity": parts[3], + }) + except FileNotFoundError: + ps_cmd = ( + "Get-CimInstance Win32_PhysicalMemory | " + "Select-Object BankLabel,Speed,SerialNumber,Capacity | ConvertTo-Json" + ) + out = subprocess.run( + ["powershell", "-NoProfile", "-Command", ps_cmd], + capture_output=True, + text=True, + timeout=60, + ) + data = json.loads(out.stdout or "[]") + if isinstance(data, dict): + data = [data] + for stick in data: + entries.append({ + "slot": stick.get("BankLabel", "unknown"), + "speed": str(stick.get("Speed", "unknown")), + "serial": stick.get("SerialNumber", "unknown"), + "capacity": stick.get("Capacity", "unknown"), + }) + elif plat == "linux": + out = subprocess.run(["dmidecode", "-t", "17"], capture_output=True, text=True) + slot = speed = serial = capacity = None + for line in out.stdout.splitlines(): + line = line.strip() + if line.startswith("Locator:"): + slot = line.split(":", 1)[1].strip() + elif line.startswith("Speed:"): + speed = line.split(":", 1)[1].strip() + elif line.startswith("Serial Number:"): + serial = line.split(":", 1)[1].strip() + elif line.startswith("Size:"): + capacity = line.split(":", 1)[1].strip() + elif not line and slot: + entries.append({ + "slot": slot, + "speed": speed or "unknown", + "serial": serial or "unknown", + "capacity": capacity or "unknown", + }) + slot = speed = serial = capacity = None + if slot: + entries.append({ + "slot": slot, + "speed": speed or "unknown", + "serial": serial or "unknown", + "capacity": capacity or "unknown", + }) + except Exception as e: + print(f"[WARN] collect_memory failed: {e}") + + if not entries: + try: + if psutil: + vm = psutil.virtual_memory() + entries.append({ + "slot": "physical", + "speed": "unknown", + "serial": "unknown", + "capacity": vm.total, + }) + except Exception: + pass + return entries + +def collect_storage(): + disks = [] + plat = platform.system().lower() + try: + if psutil: + for part in psutil.disk_partitions(): + try: + usage = psutil.disk_usage(part.mountpoint) + except Exception: + continue + disks.append({ + "drive": part.device, + "disk_type": "Removable" if "removable" in part.opts.lower() else "Fixed Disk", + "usage": usage.percent, + "total": usage.total, + "free": 100 - usage.percent, + }) + elif plat == "windows": + try: + out = subprocess.run( + ["wmic", "logicaldisk", "get", "DeviceID,Size,FreeSpace"], + capture_output=True, + text=True, + timeout=60, + ) + lines = [l for l in out.stdout.splitlines() if l.strip()][1:] + for line in lines: + parts = line.split() + if len(parts) >= 3: + drive, free, size = parts[0], parts[1], parts[2] + try: + total = float(size) + free_bytes = float(free) + used = total - free_bytes + usage = (used / total * 100) if total else 0 + free_pct = 100 - usage + disks.append({ + "drive": drive, + "disk_type": "Fixed Disk", + "usage": usage, + "total": total, + "free": free_pct, + }) + except Exception: + pass + except FileNotFoundError: + ps_cmd = ( + "Get-PSDrive -PSProvider FileSystem | " + "Select-Object Name,Free,Used,Capacity,Root | ConvertTo-Json" + ) + out = subprocess.run( + ["powershell", "-NoProfile", "-Command", ps_cmd], + capture_output=True, + text=True, + timeout=60, + ) + data = json.loads(out.stdout or "[]") + if isinstance(data, dict): + data = [data] + for d in data: + total = d.get("Capacity") or 0 + used = d.get("Used") or 0 + usage = (used / total * 100) if total else 0 + free = 100 - usage + drive = d.get("Root") or f"{d.get('Name','')}:" + disks.append({ + "drive": drive, + "disk_type": "Fixed Disk", + "usage": usage, + "total": total, + "free": free, + }) + else: + out = subprocess.run( + ["df", "-kP"], capture_output=True, text=True, timeout=60 + ) + lines = out.stdout.strip().splitlines()[1:] + for line in lines: + parts = line.split() + if len(parts) >= 6: + total = int(parts[1]) * 1024 + used = int(parts[2]) * 1024 + usage = float(parts[4].rstrip("%")) + free = 100 - usage + disks.append({ + "drive": parts[5], + "disk_type": "Fixed Disk", + "usage": usage, + "total": total, + "free": free, + }) + except Exception as e: + print(f"[WARN] collect_storage failed: {e}") + return disks + +def collect_network(): + adapters = [] + plat = platform.system().lower() + try: + if psutil: + for name, addrs in psutil.net_if_addrs().items(): + ips = [a.address for a in addrs if getattr(a, "family", None) == socket.AF_INET] + mac = next((a.address for a in addrs if getattr(a, "family", None) == getattr(psutil, "AF_LINK", object)), "unknown") + adapters.append({"adapter": name, "ips": ips, "mac": mac}) + elif plat == "windows": + ps_cmd = ( + "Get-NetIPConfiguration | " + "Select-Object InterfaceAlias,@{Name='IPv4';Expression={$_.IPv4Address.IPAddress}}," + "@{Name='MAC';Expression={$_.NetAdapter.MacAddress}} | ConvertTo-Json" + ) + out = subprocess.run( + ["powershell", "-NoProfile", "-Command", ps_cmd], + capture_output=True, + text=True, + timeout=60, + ) + data = json.loads(out.stdout or "[]") + if isinstance(data, dict): + data = [data] + for a in data: + ip = a.get("IPv4") + adapters.append({ + "adapter": a.get("InterfaceAlias", "unknown"), + "ips": [ip] if ip else [], + "mac": a.get("MAC", "unknown"), + }) + else: + out = subprocess.run( + ["ip", "-o", "-4", "addr", "show"], + capture_output=True, + text=True, + timeout=60, + ) + for line in out.stdout.splitlines(): + parts = line.split() + if len(parts) >= 4: + name = parts[1] + ip = parts[3].split("/")[0] + adapters.append({"adapter": name, "ips": [ip], "mac": "unknown"}) + except Exception as e: + print(f"[WARN] collect_network failed: {e}") + return adapters + +async def send_agent_details(): + """Collect detailed agent data and send to server periodically.""" + while True: + try: + details = { + "summary": collect_summary(), + "software": collect_software(), + "memory": collect_memory(), + "storage": collect_storage(), + "network": collect_network(), + } + url = CONFIG.data.get("borealis_server_url", "http://localhost:5000") + "/api/agent/details" + payload = { + "agent_id": AGENT_ID, + "hostname": details.get("summary", {}).get("hostname", socket.gethostname()), + "details": details, + } + async with aiohttp.ClientSession() as session: + await session.post(url, json=payload, timeout=10) + except Exception as e: + print(f"[WARN] Failed to send agent details: {e}") + await asyncio.sleep(300) + @sio.event async def connect(): print(f"[WebSocket] Connected to Borealis Server with Agent ID: {AGENT_ID}") @@ -593,6 +1004,7 @@ if __name__=='__main__': background_tasks.append(loop.create_task(idle_task())) # Start periodic heartbeats background_tasks.append(loop.create_task(send_heartbeat())) + background_tasks.append(loop.create_task(send_agent_details())) loop.run_forever() except Exception as e: print(f"[FATAL] Event loop crashed: {e}") diff --git a/Data/Server/WebUI/src/App.jsx b/Data/Server/WebUI/src/App.jsx index 556e4f5..56307af 100644 --- a/Data/Server/WebUI/src/App.jsx +++ b/Data/Server/WebUI/src/App.jsx @@ -29,6 +29,7 @@ import DeviceList from "./Device_List"; import ScriptList from "./Script_List"; import ScheduledJobsList from "./Scheduled_Jobs_List"; import Login from "./Login.jsx"; +import DeviceDetails from "./Device_Details"; import { io } from "socket.io-client"; @@ -77,6 +78,7 @@ export default function App() { const [tabs, setTabs] = useState([{ id: "flow_1", tab_name: "Flow 1", nodes: [], edges: [] }]); const [activeTabId, setActiveTabId] = useState("flow_1"); const [currentPage, setCurrentPage] = useState("devices"); + const [selectedDevice, setSelectedDevice] = useState(null); const [aboutAnchorEl, setAboutAnchorEl] = useState(null); const [creditsDialogOpen, setCreditsDialogOpen] = useState(false); @@ -288,7 +290,25 @@ export default function App() { const renderMainContent = () => { switch (currentPage) { case "devices": - return ; + return ( + { + setSelectedDevice(d); + setCurrentPage("device_details"); + }} + /> + ); + + case "device_details": + return ( + { + setCurrentPage("devices"); + setSelectedDevice(null); + }} + /> + ); case "jobs": return ; diff --git a/Data/Server/WebUI/src/Device_Details.jsx b/Data/Server/WebUI/src/Device_Details.jsx new file mode 100644 index 0000000..816e792 --- /dev/null +++ b/Data/Server/WebUI/src/Device_Details.jsx @@ -0,0 +1,402 @@ +////////// PROJECT FILE SEPARATION LINE ////////// CODE AFTER THIS LINE ARE FROM: /Data/WebUI/src/Device_Details.js + +import React, { useState, useEffect, useMemo } from "react"; +import { + Paper, + Box, + Tabs, + Tab, + Typography, + Table, + TableHead, + TableRow, + TableCell, + TableBody, + Button, + LinearProgress, + TableSortLabel, + TextField +} from "@mui/material"; + +export default function DeviceDetails({ device, onBack }) { + const [tab, setTab] = useState(0); + const [agent, setAgent] = useState(device || {}); + const [details, setDetails] = useState({}); + const [softwareOrderBy, setSoftwareOrderBy] = useState("name"); + const [softwareOrder, setSoftwareOrder] = useState("asc"); + const [softwareSearch, setSoftwareSearch] = useState(""); + const [description, setDescription] = useState(""); + + useEffect(() => { + if (!device || !device.hostname) return; + const load = async () => { + try { + const [agentsRes, detailsRes] = await Promise.all([ + fetch("/api/agents"), + fetch(`/api/device/details/${device.hostname}`) + ]); + const agentsData = await agentsRes.json(); + if (agentsData && agentsData[device.id]) { + setAgent({ id: device.id, ...agentsData[device.id] }); + } + const detailData = await detailsRes.json(); + setDetails(detailData || {}); + setDescription(detailData?.summary?.description || ""); + } catch (e) { + console.warn("Failed to load device info", e); + } + }; + load(); + }, [device]); + + const saveDescription = async () => { + if (!details.summary?.hostname) return; + try { + await fetch(`/api/device/description/${details.summary.hostname}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ description }) + }); + setDetails((d) => ({ + ...d, + summary: { ...(d.summary || {}), description } + })); + } catch (e) { + console.warn("Failed to save description", e); + } + }; + + const formatDateTime = (str) => { + if (!str) return "unknown"; + try { + const [datePart, timePart] = str.split(" "); + const [y, m, d] = datePart.split("-").map(Number); + let [hh, mm, ss] = timePart.split(":").map(Number); + const ampm = hh >= 12 ? "PM" : "AM"; + hh = hh % 12 || 12; + return `${m.toString().padStart(2, "0")}/${d.toString().padStart(2, "0")}/${y} - ${hh}:${mm + .toString() + .padStart(2, "0")}${ampm}`; + } catch { + return str; + } + }; + + const formatMac = (mac) => (mac ? mac.replace(/-/g, ":").toUpperCase() : "unknown"); + + const formatBytes = (val) => { + if (val === undefined || val === null || val === "unknown") return "unknown"; + let num = Number(val); + const units = ["B", "KB", "MB", "GB", "TB"]; + let i = 0; + while (num >= 1024 && i < units.length - 1) { + num /= 1024; + i++; + } + return `${num.toFixed(1)} ${units[i]}`; + }; + + const handleSoftwareSort = (col) => { + if (softwareOrderBy === col) { + setSoftwareOrder(softwareOrder === "asc" ? "desc" : "asc"); + } else { + setSoftwareOrderBy(col); + setSoftwareOrder("asc"); + } + }; + + const softwareRows = useMemo(() => { + const rows = details.software || []; + const filtered = rows.filter((s) => + s.name.toLowerCase().includes(softwareSearch.toLowerCase()) + ); + const dir = softwareOrder === "asc" ? 1 : -1; + return [...filtered].sort((a, b) => { + const A = a[softwareOrderBy] || ""; + const B = b[softwareOrderBy] || ""; + return String(A).localeCompare(String(B)) * dir; + }); + }, [details.software, softwareSearch, softwareOrderBy, softwareOrder]); + + const summary = details.summary || {}; + const summaryItems = [ + { label: "Device Name", value: summary.hostname || agent.hostname || device?.hostname || "unknown" }, + { label: "Operating System", value: summary.operating_system || agent.agent_operating_system || "unknown" }, + { label: "Last User", value: summary.last_user || "unknown" }, + { label: "Internal IP", value: summary.internal_ip || "unknown" }, + { label: "External IP", value: summary.external_ip || "unknown" }, + { label: "Last Reboot", value: summary.last_reboot ? formatDateTime(summary.last_reboot) : "unknown" }, + { label: "Created", value: summary.created ? formatDateTime(summary.created) : "unknown" } + ]; + + const renderSummary = () => ( + + + + + Description + + setDescription(e.target.value)} + onBlur={saveDescription} + placeholder="Enter description" + sx={{ + input: { color: "#fff" }, + "& .MuiOutlinedInput-root": { + "& fieldset": { borderColor: "#555" }, + "&:hover fieldset": { borderColor: "#888" } + } + }} + /> + + + {summaryItems.map((item) => ( + + {item.label} + {item.value} + + ))} + +
+
+ ); + + const placeholderTable = (headers) => ( + + + + + {headers.map((h) => ( + {h} + ))} + + + + + + No data available. + + + +
+
+ ); + + const renderSoftware = () => { + if (!softwareRows.length) + return placeholderTable(["Software Name", "Version", "Action"]); + + return ( + + + setSoftwareSearch(e.target.value)} + sx={{ + input: { color: "#fff" }, + "& .MuiOutlinedInput-root": { + "& fieldset": { borderColor: "#555" }, + "&:hover fieldset": { borderColor: "#888" } + } + }} + /> + + + + + + + handleSoftwareSort("name")} + > + Software Name + + + + handleSoftwareSort("version")} + > + Version + + + Action + + + + {softwareRows.map((s, i) => ( + + {s.name} + {s.version} + + + ))} + +
+
+
+ ); + }; + + const renderMemory = () => { + const rows = details.memory || []; + if (!rows.length) return placeholderTable(["Slot", "Speed", "Serial Number", "Capacity"]); + return ( + + + + + Slot + Speed + Serial Number + Capacity + + + + {rows.map((m, i) => ( + + {m.slot} + {m.speed} + {m.serial} + {formatBytes(m.capacity)} + + ))} + +
+
+ ); + }; + + const renderStorage = () => { + const rows = (details.storage || []).map((d) => ({ + drive: d.drive, + disk_type: d.disk_type, + usage: d.usage !== undefined ? Number(d.usage) : undefined, + total: d.total !== undefined ? Number(d.total) : undefined, + free: d.free !== undefined ? Number(d.free) : undefined, + })); + if (!rows.length) + return placeholderTable(["Drive Letter", "Disk Type", "Usage", "Total Size", "Free %"]); + return ( + + + + + Drive Letter + Disk Type + Usage + Total Size + Free % + + + + {rows.map((d, i) => ( + + {d.drive} + {d.disk_type} + + + + + + + {d.usage !== undefined && !Number.isNaN(d.usage) + ? `${d.usage.toFixed(1)}%` + : "unknown"} + + + + + {d.total !== undefined && !Number.isNaN(d.total) + ? formatBytes(d.total) + : "unknown"} + + + {d.free !== undefined && !Number.isNaN(d.free) + ? `${d.free.toFixed(1)}%` + : "unknown"} + + + ))} + +
+
+ ); + }; + + const renderNetwork = () => { + const rows = details.network || []; + if (!rows.length) return placeholderTable(["Adapter", "IP Address", "MAC Address"]); + return ( + + + + + Adapter + IP Address + MAC Address + + + + {rows.map((n, i) => ( + + {n.adapter} + {(n.ips || []).join(", ")} + {formatMac(n.mac)} + + ))} + +
+
+ ); + }; + + const tabs = [ + { label: "Summary", content: renderSummary() }, + { label: "Software", content: renderSoftware() }, + { label: "Memory", content: renderMemory() }, + { label: "Storage", content: renderStorage() }, + { label: "Network", content: renderNetwork() } + ]; + + return ( + + + {onBack && ( + + )} + + {agent.hostname || "Device Details"} + + + setTab(v)} + sx={{ borderBottom: 1, borderColor: "#333" }} + > + {tabs.map((t) => ( + + ))} + + {tabs[tab].content} + + ); +} + diff --git a/Data/Server/WebUI/src/Device_List.jsx b/Data/Server/WebUI/src/Device_List.jsx index 280e33b..0799c4c 100644 --- a/Data/Server/WebUI/src/Device_List.jsx +++ b/Data/Server/WebUI/src/Device_List.jsx @@ -35,7 +35,7 @@ function statusFromHeartbeat(tsSec, offlineAfter = 15) { return now - tsSec <= offlineAfter ? "Online" : "Offline"; } -export default function DeviceList() { +export default function DeviceList({ onSelectDevice }) { const [rows, setRows] = useState([]); const [orderBy, setOrderBy] = useState("status"); const [order, setOrder] = useState("desc"); @@ -165,7 +165,12 @@ export default function DeviceList() { {sorted.map((r, i) => ( - + onSelectDevice && onSelectDevice(r)} + sx={{ cursor: onSelectDevice ? "pointer" : "default" }} + > ", methods=["GET"]) +def get_device_details(hostname: str): + try: + conn = sqlite3.connect(DB_PATH) + cur = conn.cursor() + cur.execute( + "SELECT details, description FROM device_details WHERE hostname = ?", + (hostname,), + ) + row = cur.fetchone() + conn.close() + if row: + try: + details = json.loads(row[0]) + except Exception: + details = {} + description = row[1] if len(row) > 1 else "" + if description: + details.setdefault("summary", {})["description"] = description + return jsonify(details) + except Exception: + pass + return jsonify({}) + + +@app.route("/api/device/description/", methods=["POST"]) +def set_device_description(hostname: str): + data = request.get_json(silent=True) or {} + description = (data.get("description") or "").strip() + try: + conn = sqlite3.connect(DB_PATH) + cur = conn.cursor() + cur.execute( + "INSERT INTO device_details(hostname, description, details) VALUES (?, ?, COALESCE((SELECT details FROM device_details WHERE hostname = ?), '{}')) " + "ON CONFLICT(hostname) DO UPDATE SET description=excluded.description", + (hostname, description, hostname), + ) + conn.commit() + conn.close() + return jsonify({"status": "ok"}) + except Exception as e: + return jsonify({"error": str(e)}), 500 + + @app.route("/api/agent/", methods=["DELETE"]) def delete_agent(agent_id: str): """Remove an agent from the in-memory registry."""