diff --git a/.gitignore b/.gitignore index b00fc68..f5ce6fe 100644 --- a/.gitignore +++ b/.gitignore @@ -17,7 +17,7 @@ Borealis-Server.exe /ElectronApp/ # Misc Files/Folders -.vs/ +.vs/s /Update_Staging/ /Macro_Testing/ @@ -27,5 +27,5 @@ Borealis-Server.exe /Dependencies/AutoHotKey/ /Data/Server/Python_API_Endpoints/Tesseract-OCR/ -# Deployed Environment Folders -/Devices/ \ No newline at end of file +# Server Database +/Databases/ \ No newline at end of file diff --git a/Data/Agent/borealis-agent.py b/Data/Agent/borealis-agent.py index fef724c..00df6b1 100644 --- a/Data/Agent/borealis-agent.py +++ b/Data/Agent/borealis-agent.py @@ -16,6 +16,7 @@ import importlib.util import time # Heartbeat timestamps import subprocess import getpass +import datetime import requests try: @@ -228,13 +229,60 @@ def _get_internal_ip(): def collect_summary(): try: - last_user = getpass.getuser() + 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: - last_reboot = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(psutil.boot_time())) + 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" @@ -264,14 +312,41 @@ def collect_software(): plat = platform.system().lower() try: if plat == "windows": - 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 "" + 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": version}) + 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(): @@ -292,21 +367,43 @@ def collect_memory(): plat = platform.system().lower() try: if plat == "windows": - out = subprocess.run([ - "wmic", - "memorychip", - "get", - "BankLabel,Speed,SerialNumber,Capacity" - ], capture_output=True, text=True) - 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: + 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": parts[0], - "speed": parts[1], - "serial": parts[2], - "capacity": parts[3], + "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) @@ -355,6 +452,7 @@ def collect_memory(): def collect_storage(): disks = [] + plat = platform.system().lower() try: if psutil: for part in psutil.disk_partitions(): @@ -369,18 +467,128 @@ def collect_storage(): "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 @@ -397,8 +605,13 @@ async def send_agent_details(): "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={"agent_id": AGENT_ID, "details": details}, timeout=10) + 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) diff --git a/Data/Server/WebUI/src/Device_Details.jsx b/Data/Server/WebUI/src/Device_Details.jsx index ef48df9..816e792 100644 --- a/Data/Server/WebUI/src/Device_Details.jsx +++ b/Data/Server/WebUI/src/Device_Details.jsx @@ -1,6 +1,6 @@ -////////// PROJECT FILE SEPARATION LINE ////////// CODE AFTER THIS LINE ARE FROM: /Data/WebUI/src/Device_Details.jsx +////////// PROJECT FILE SEPARATION LINE ////////// CODE AFTER THIS LINE ARE FROM: /Data/WebUI/src/Device_Details.js -import React, { useState, useEffect } from "react"; +import React, { useState, useEffect, useMemo } from "react"; import { Paper, Box, @@ -12,21 +12,28 @@ import { TableRow, TableCell, TableBody, - Button + 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.id) return; + if (!device || !device.hostname) return; const load = async () => { try { const [agentsRes, detailsRes] = await Promise.all([ fetch("/api/agents"), - fetch(`/api/agent/details/${device.id}`) + fetch(`/api/device/details/${device.hostname}`) ]); const agentsData = await agentsRes.json(); if (agentsData && agentsData[device.id]) { @@ -34,6 +41,7 @@ export default function DeviceDetails({ device, onBack }) { } const detailData = await detailsRes.json(); setDetails(detailData || {}); + setDescription(detailData?.summary?.description || ""); } catch (e) { console.warn("Failed to load device info", e); } @@ -41,6 +49,41 @@ export default function DeviceDetails({ device, onBack }) { 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); @@ -53,72 +96,152 @@ export default function DeviceDetails({ device, onBack }) { 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: "Description", value: summary.description || "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 || "unknown" }, - { label: "Created", value: summary.created || "unknown" } + { label: "Last Reboot", value: summary.last_reboot ? formatDateTime(summary.last_reboot) : "unknown" }, + { label: "Created", value: summary.created ? formatDateTime(summary.created) : "unknown" } ]; const renderSummary = () => ( - - - {summaryItems.map((item) => ( - - {item.label} - {item.value} - - ))} - -
- ); - - const placeholderTable = (headers) => ( - - - - {headers.map((h) => ( - {h} - ))} - - - - - - No data available. - - - -
- ); - - const renderSoftware = () => { - const rows = details.software || []; - if (!rows.length) return placeholderTable(["Software Name", "Version", "Action"]); - return ( + - - - Software Name - Version - Action - - - {rows.map((s, i) => ( - - {s.name} - {s.version} - + + 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} + + + ))} + +
+
+
); }; @@ -126,64 +249,93 @@ export default function DeviceDetails({ device, onBack }) { 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)} + +
+ + + Slot + Speed + Serial Number + Capacity - ))} - -
+ + + {rows.map((m, i) => ( + + {m.slot} + {m.speed} + {m.serial} + {formatBytes(m.capacity)} + + ))} + + + ); }; const renderStorage = () => { - const rows = details.storage || []; + 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 && d.usage !== null && d.usage !== "unknown" - ? `${d.usage.toFixed ? d.usage.toFixed(1) : d.usage}%` - : "unknown"} - - {formatBytes(d.total)} - - {d.free !== undefined && d.free !== null && d.free !== "unknown" - ? `${d.free.toFixed ? d.free.toFixed(1) : d.free}%` - : "unknown"} - + +
+ + + 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"} + + + ))} + + + ); }; @@ -191,40 +343,31 @@ export default function DeviceDetails({ device, onBack }) { 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(", ")} - {n.mac} + +
+ + + Adapter + IP Address + MAC Address - ))} - -
+ + + {rows.map((n, i) => ( + + {n.adapter} + {(n.ips || []).join(", ")} + {formatMac(n.mac)} + + ))} + + + ); }; const tabs = [ { label: "Summary", content: renderSummary() }, - { - label: "Monitors", - content: placeholderTable([ - "Type", - "Description", - "Latest Value", - "Policy", - "Latest 10 Days of Alerts", - "Enabled/Disabled Status" - ]) - }, { label: "Software", content: renderSoftware() }, { label: "Memory", content: renderMemory() }, { label: "Storage", content: renderStorage() }, diff --git a/Data/Server/server.py b/Data/Server/server.py index 658d614..8e0474e 100644 --- a/Data/Server/server.py +++ b/Data/Server/server.py @@ -14,6 +14,7 @@ import os # To Read Production ReactJS Server Folder import json # For reading workflow JSON files import shutil # For moving workflow files and folders from typing import List, Dict +import sqlite3 # Borealis Python API Endpoints from Python_API_Endpoints.ocr_engines import run_ocr_on_base64 @@ -379,7 +380,23 @@ def rename_workflow(): registered_agents: Dict[str, Dict] = {} agent_configurations: Dict[str, Dict] = {} latest_images: Dict[str, Dict] = {} -DEVICES_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "Devices")) + +# Device database initialization +DB_PATH = os.path.abspath( + os.path.join(os.path.dirname(__file__), "..", "..", "Databases", "devices.db") +) +os.makedirs(os.path.dirname(DB_PATH), exist_ok=True) + +def init_db(): + conn = sqlite3.connect(DB_PATH) + cur = conn.cursor() + cur.execute( + "CREATE TABLE IF NOT EXISTS device_details (hostname TEXT PRIMARY KEY, description TEXT, details TEXT)" + ) + conn.commit() + conn.close() + +init_db() @app.route("/api/agents") def get_agents(): @@ -392,32 +409,76 @@ def get_agents(): @app.route("/api/agent/details", methods=["POST"]) def save_agent_details(): data = request.get_json(silent=True) or {} - agent_id = data.get("agent_id") + hostname = data.get("hostname") details = data.get("details") - if not agent_id or not isinstance(details, dict): + if not hostname and isinstance(details, dict): + hostname = details.get("summary", {}).get("hostname") + if not hostname or not isinstance(details, dict): return jsonify({"error": "invalid payload"}), 400 - os.makedirs(DEVICES_ROOT, exist_ok=True) - path = os.path.join(DEVICES_ROOT, f"{agent_id}.json") try: - with open(path, "w", encoding="utf-8") as fh: - json.dump(details, fh, indent=2) + conn = sqlite3.connect(DB_PATH) + cur = conn.cursor() + cur.execute( + "SELECT description FROM device_details WHERE hostname = ?", + (hostname,), + ) + row = cur.fetchone() + description = row[0] if row else "" + cur.execute( + "REPLACE INTO device_details (hostname, description, details) VALUES (?, ?, ?)", + (hostname, description, json.dumps(details)), + ) + conn.commit() + conn.close() return jsonify({"status": "ok"}) except Exception as e: return jsonify({"error": str(e)}), 500 -@app.route("/api/agent/details/", methods=["GET"]) -def get_agent_details(agent_id: str): - path = os.path.join(DEVICES_ROOT, f"{agent_id}.json") - if os.path.isfile(path): - try: - with open(path, "r", encoding="utf-8") as fh: - return jsonify(json.load(fh)) - except Exception: - pass +@app.route("/api/device/details/", 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."""