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."""