mirror of
https://github.com/bunny-lab-io/Borealis.git
synced 2025-09-11 01:48:42 -06:00
Initial Device Type Logic
This commit is contained in:
@@ -152,6 +152,237 @@ def detect_agent_os():
|
|||||||
return "Unknown"
|
return "Unknown"
|
||||||
|
|
||||||
|
|
||||||
|
def _detect_virtual_machine() -> bool:
|
||||||
|
"""Best-effort detection of whether this system is a virtual machine.
|
||||||
|
|
||||||
|
Uses platform-specific signals but avoids heavy dependencies.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
plat = platform.system().lower()
|
||||||
|
|
||||||
|
if plat == "linux":
|
||||||
|
# Prefer systemd-detect-virt if available
|
||||||
|
try:
|
||||||
|
out = subprocess.run([
|
||||||
|
"systemd-detect-virt", "--vm"
|
||||||
|
], capture_output=True, text=True, timeout=3)
|
||||||
|
if out.returncode == 0 and (out.stdout or "").strip():
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Fallback to DMI sysfs strings
|
||||||
|
for p in (
|
||||||
|
"/sys/class/dmi/id/product_name",
|
||||||
|
"/sys/class/dmi/id/sys_vendor",
|
||||||
|
"/sys/class/dmi/id/board_vendor",
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
with open(p, "r", encoding="utf-8", errors="ignore") as fh:
|
||||||
|
s = (fh.read() or "").lower()
|
||||||
|
if any(k in s for k in (
|
||||||
|
"kvm", "vmware", "virtualbox", "qemu", "xen", "hyper-v", "microsoft corporation")):
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
elif plat == "windows":
|
||||||
|
# Inspect model/manufacturer via CIM
|
||||||
|
try:
|
||||||
|
ps_cmd = (
|
||||||
|
"$cs = Get-CimInstance Win32_ComputerSystem; "
|
||||||
|
"$model = [string]$cs.Model; $manu = [string]$cs.Manufacturer; "
|
||||||
|
"Write-Output ($model + '|' + $manu)"
|
||||||
|
)
|
||||||
|
out = subprocess.run(
|
||||||
|
["powershell", "-NoProfile", "-Command", ps_cmd],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=6,
|
||||||
|
)
|
||||||
|
s = (out.stdout or "").strip().lower()
|
||||||
|
if any(k in s for k in ("virtual", "vmware", "virtualbox", "kvm", "qemu", "xen", "hyper-v")):
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Fallback: registry BIOS strings often include virtualization hints
|
||||||
|
try:
|
||||||
|
import winreg # type: ignore
|
||||||
|
with winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, r"HARDWARE\DESCRIPTION\System") as k:
|
||||||
|
for name in ("SystemBiosVersion", "VideoBiosVersion"):
|
||||||
|
try:
|
||||||
|
val, _ = winreg.QueryValueEx(k, name)
|
||||||
|
s = str(val).lower()
|
||||||
|
if any(x in s for x in ("virtual", "vmware", "virtualbox", "qemu", "xen", "hyper-v")):
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
elif plat == "darwin":
|
||||||
|
# macOS guest detection is tricky; limited heuristic
|
||||||
|
try:
|
||||||
|
out = subprocess.run([
|
||||||
|
"sysctl", "-n", "machdep.cpu.features"
|
||||||
|
], capture_output=True, text=True, timeout=3)
|
||||||
|
s = (out.stdout or "").lower()
|
||||||
|
if "vmm" in s:
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _detect_device_type_non_vm() -> str:
|
||||||
|
"""Classify non-VM device as Laptop, Desktop, or Server.
|
||||||
|
|
||||||
|
This is intentionally conservative; if unsure, returns 'Desktop'.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
plat = platform.system().lower()
|
||||||
|
|
||||||
|
if plat == "windows":
|
||||||
|
# Prefer PCSystemTypeEx, then chassis types, then battery presence
|
||||||
|
try:
|
||||||
|
ps_cmd = (
|
||||||
|
"$cs = Get-CimInstance Win32_ComputerSystem; "
|
||||||
|
"$typeEx = [int]($cs.PCSystemTypeEx); "
|
||||||
|
"$type = [int]($cs.PCSystemType); "
|
||||||
|
"$ch = (Get-CimInstance Win32_SystemEnclosure).ChassisTypes; "
|
||||||
|
"$hasBatt = @(Get-CimInstance Win32_Battery).Count -gt 0; "
|
||||||
|
"Write-Output ($typeEx.ToString() + '|' + $type.ToString() + '|' + "
|
||||||
|
"([string]::Join(',', $ch)) + '|' + $hasBatt)"
|
||||||
|
)
|
||||||
|
out = subprocess.run(
|
||||||
|
["powershell", "-NoProfile", "-Command", ps_cmd],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=6,
|
||||||
|
)
|
||||||
|
resp = (out.stdout or "").strip()
|
||||||
|
parts = resp.split("|")
|
||||||
|
type_ex = int(parts[0]) if len(parts) > 0 and parts[0].isdigit() else None
|
||||||
|
type_pc = int(parts[1]) if len(parts) > 1 and parts[1].isdigit() else None
|
||||||
|
chassis = []
|
||||||
|
if len(parts) > 2 and parts[2].strip():
|
||||||
|
for t in parts[2].split(','):
|
||||||
|
t = t.strip()
|
||||||
|
if t.isdigit():
|
||||||
|
chassis.append(int(t))
|
||||||
|
has_batt = False
|
||||||
|
if len(parts) > 3:
|
||||||
|
has_batt = parts[3].strip().lower() in ("true", "1")
|
||||||
|
|
||||||
|
# PCSystemTypeEx mapping per MS docs
|
||||||
|
if type_ex in (4, 5, 7, 8):
|
||||||
|
return "Server"
|
||||||
|
if type_ex == 2:
|
||||||
|
return "Laptop"
|
||||||
|
if type_ex in (1, 3):
|
||||||
|
return "Desktop"
|
||||||
|
|
||||||
|
# Fallback to PCSystemType
|
||||||
|
if type_pc in (4, 5):
|
||||||
|
return "Server"
|
||||||
|
if type_pc == 2:
|
||||||
|
return "Laptop"
|
||||||
|
if type_pc in (1, 3):
|
||||||
|
return "Desktop"
|
||||||
|
|
||||||
|
# ChassisType mapping (DMTF)
|
||||||
|
laptop_types = {8, 9, 10, 14, 30, 31}
|
||||||
|
server_types = {17, 23}
|
||||||
|
desktop_types = {3, 4, 5, 6, 7, 15, 16, 24, 35}
|
||||||
|
if any(ct in laptop_types for ct in chassis):
|
||||||
|
return "Laptop"
|
||||||
|
if any(ct in server_types for ct in chassis):
|
||||||
|
return "Server"
|
||||||
|
if any(ct in desktop_types for ct in chassis):
|
||||||
|
return "Desktop"
|
||||||
|
|
||||||
|
if has_batt:
|
||||||
|
return "Laptop"
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return "Desktop"
|
||||||
|
|
||||||
|
if plat == "linux":
|
||||||
|
# hostnamectl exposes chassis when available
|
||||||
|
try:
|
||||||
|
out = subprocess.run(["hostnamectl"], capture_output=True, text=True, timeout=3)
|
||||||
|
for line in (out.stdout or "").splitlines():
|
||||||
|
if ":" in line:
|
||||||
|
k, v = line.split(":", 1)
|
||||||
|
if k.strip().lower() == "chassis":
|
||||||
|
val = v.strip().lower()
|
||||||
|
if "laptop" in val:
|
||||||
|
return "Laptop"
|
||||||
|
if "desktop" in val:
|
||||||
|
return "Desktop"
|
||||||
|
if "server" in val:
|
||||||
|
return "Server"
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# DMI chassis type numeric
|
||||||
|
try:
|
||||||
|
with open("/sys/class/dmi/id/chassis_type", "r", encoding="utf-8", errors="ignore") as fh:
|
||||||
|
s = (fh.read() or "").strip()
|
||||||
|
ct = int(s)
|
||||||
|
laptop_types = {8, 9, 10, 14, 30, 31}
|
||||||
|
server_types = {17, 23}
|
||||||
|
desktop_types = {3, 4, 5, 6, 7, 15, 16, 24, 35}
|
||||||
|
if ct in laptop_types:
|
||||||
|
return "Laptop"
|
||||||
|
if ct in server_types:
|
||||||
|
return "Server"
|
||||||
|
if ct in desktop_types:
|
||||||
|
return "Desktop"
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Battery presence heuristic
|
||||||
|
try:
|
||||||
|
if os.path.isdir("/sys/class/power_supply"):
|
||||||
|
for name in os.listdir("/sys/class/power_supply"):
|
||||||
|
if name.lower().startswith("bat"):
|
||||||
|
return "Laptop"
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return "Desktop"
|
||||||
|
|
||||||
|
if plat == "darwin":
|
||||||
|
try:
|
||||||
|
out = subprocess.run(["sysctl", "-n", "hw.model"], capture_output=True, text=True, timeout=3)
|
||||||
|
model = (out.stdout or "").strip()
|
||||||
|
if model:
|
||||||
|
if model.lower().startswith("macbook"):
|
||||||
|
return "Laptop"
|
||||||
|
# iMac, Macmini, MacPro -> treat as Desktop
|
||||||
|
return "Desktop"
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return "Desktop"
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return "Desktop"
|
||||||
|
|
||||||
|
|
||||||
|
def detect_device_type() -> str:
|
||||||
|
"""Return one of: 'Laptop', 'Desktop', 'Server', 'Virtual Machine'."""
|
||||||
|
try:
|
||||||
|
if _detect_virtual_machine():
|
||||||
|
return "Virtual Machine"
|
||||||
|
return _detect_device_type_non_vm()
|
||||||
|
except Exception:
|
||||||
|
return "Desktop"
|
||||||
|
|
||||||
|
|
||||||
def _get_internal_ip():
|
def _get_internal_ip():
|
||||||
"""Best-effort detection of primary IPv4 address without external reachability.
|
"""Best-effort detection of primary IPv4 address without external reachability.
|
||||||
|
|
||||||
@@ -325,6 +556,7 @@ def collect_summary(config):
|
|||||||
return {
|
return {
|
||||||
"hostname": socket.gethostname(),
|
"hostname": socket.gethostname(),
|
||||||
"operating_system": config.data.get("agent_operating_system", detect_agent_os()),
|
"operating_system": config.data.get("agent_operating_system", detect_agent_os()),
|
||||||
|
"device_type": config.data.get("device_type", detect_device_type()),
|
||||||
"last_user": last_user,
|
"last_user": last_user,
|
||||||
"internal_ip": _get_internal_ip(),
|
"internal_ip": _get_internal_ip(),
|
||||||
"external_ip": external_ip,
|
"external_ip": external_ip,
|
||||||
|
@@ -211,6 +211,7 @@ export default function DeviceDetails({ device, onBack }) {
|
|||||||
const summaryItems = [
|
const summaryItems = [
|
||||||
{ label: "Device Name", value: summary.hostname || agent.hostname || device?.hostname || "unknown" },
|
{ label: "Device Name", value: summary.hostname || agent.hostname || device?.hostname || "unknown" },
|
||||||
{ label: "Operating System", value: summary.operating_system || agent.agent_operating_system || "unknown" },
|
{ label: "Operating System", value: summary.operating_system || agent.agent_operating_system || "unknown" },
|
||||||
|
{ label: "Device Type", value: summary.device_type || "unknown" },
|
||||||
{ label: "Last User", value: (
|
{ label: "Last User", value: (
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
<Box component="span" sx={{
|
<Box component="span" sx={{
|
||||||
|
@@ -95,7 +95,7 @@ export default function DeviceList({ onSelectDevice }) {
|
|||||||
os: a.agent_operating_system || a.os || "-",
|
os: a.agent_operating_system || a.os || "-",
|
||||||
// Enriched fields from details cache
|
// Enriched fields from details cache
|
||||||
lastUser: details.lastUser || "",
|
lastUser: details.lastUser || "",
|
||||||
type: "", // Placeholder until provided by backend
|
type: a.device_type || details.type || "",
|
||||||
created: details.created || "",
|
created: details.created || "",
|
||||||
createdTs: details.createdTs || 0,
|
createdTs: details.createdTs || 0,
|
||||||
};
|
};
|
||||||
@@ -128,9 +128,10 @@ export default function DeviceList({ onSelectDevice }) {
|
|||||||
const parsed = Date.parse(createdRaw.replace(" ", "T"));
|
const parsed = Date.parse(createdRaw.replace(" ", "T"));
|
||||||
createdTs = isNaN(parsed) ? 0 : Math.floor(parsed / 1000);
|
createdTs = isNaN(parsed) ? 0 : Math.floor(parsed / 1000);
|
||||||
}
|
}
|
||||||
|
const deviceType = (summary.device_type || "").trim();
|
||||||
setDetailsByHost((prev) => ({
|
setDetailsByHost((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
[h]: { lastUser, created: createdRaw, createdTs },
|
[h]: { lastUser, created: createdRaw, createdTs, type: deviceType },
|
||||||
}));
|
}));
|
||||||
} catch {
|
} catch {
|
||||||
// ignore per-host failure
|
// ignore per-host failure
|
||||||
@@ -146,6 +147,7 @@ export default function DeviceList({ onSelectDevice }) {
|
|||||||
return {
|
return {
|
||||||
...r,
|
...r,
|
||||||
lastUser: det.lastUser || r.lastUser,
|
lastUser: det.lastUser || r.lastUser,
|
||||||
|
type: det.type || r.type,
|
||||||
created: det.created || r.created,
|
created: det.created || r.created,
|
||||||
createdTs: det.createdTs || r.createdTs,
|
createdTs: det.createdTs || r.createdTs,
|
||||||
};
|
};
|
||||||
|
@@ -743,6 +743,7 @@ def load_agents_from_db():
|
|||||||
"agent_operating_system": summary.get("operating_system")
|
"agent_operating_system": summary.get("operating_system")
|
||||||
or summary.get("agent_operating_system")
|
or summary.get("agent_operating_system")
|
||||||
or "-",
|
or "-",
|
||||||
|
"device_type": summary.get("device_type") or "",
|
||||||
"last_seen": summary.get("last_seen") or 0,
|
"last_seen": summary.get("last_seen") or 0,
|
||||||
"status": "Offline",
|
"status": "Offline",
|
||||||
}
|
}
|
||||||
@@ -809,6 +810,18 @@ def save_agent_details():
|
|||||||
last_seen = (prev_details.get("summary") or {}).get("last_seen")
|
last_seen = (prev_details.get("summary") or {}).get("last_seen")
|
||||||
if last_seen:
|
if last_seen:
|
||||||
incoming_summary["last_seen"] = int(last_seen)
|
incoming_summary["last_seen"] = int(last_seen)
|
||||||
|
# Refresh server-side cache so /api/agents includes latest OS and device type
|
||||||
|
try:
|
||||||
|
if agent_id and agent_id in registered_agents:
|
||||||
|
rec = registered_agents[agent_id]
|
||||||
|
os_name = incoming_summary.get("operating_system") or incoming_summary.get("agent_operating_system")
|
||||||
|
if os_name:
|
||||||
|
rec["agent_operating_system"] = os_name
|
||||||
|
dt = (incoming_summary.get("device_type") or "").strip()
|
||||||
|
if dt:
|
||||||
|
rec["device_type"] = dt
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
Reference in New Issue
Block a user